Initial Codes

This commit is contained in:
NguyenND
2025-07-29 17:02:08 -04:00
committed by GitHub
parent 946b18f142
commit 7459d0fd15
34 changed files with 23480 additions and 0 deletions
+1540
View File
File diff suppressed because it is too large Load Diff
+1459
View File
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""
Database Update Script for QR Code Management System
Adds attendance tracking functionality to existing database
"""
import os
import sys
from datetime import datetime
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text
def create_app():
"""Create Flask application for database updates"""
app = Flask(__name__)
# Database configuration
database_url = os.environ.get('DATABASE_URL', 'postgresql://postgres:Ratkhonho123@localhost/qr_management')
app.config['SQLALCHEMY_DATABASE_URI'] = database_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'update-key-change-in-production'
return app
def add_attendance_table():
"""Add attendance_data table to existing database"""
print("🔄 Adding attendance tracking functionality...")
print("=" * 60)
app = create_app()
db = SQLAlchemy(app)
with app.app_context():
try:
# Create attendance_data table
create_table_sql = """
CREATE TABLE IF NOT EXISTS attendance_data (
id SERIAL PRIMARY KEY,
qr_code_id INTEGER NOT NULL REFERENCES qr_codes(id) ON DELETE CASCADE,
employee_id VARCHAR(50) NOT NULL,
check_in_date DATE NOT NULL,
check_in_time TIME NOT NULL,
device_info VARCHAR(200),
user_agent TEXT,
ip_address VARCHAR(45),
location_name VARCHAR(100) NOT NULL,
status VARCHAR(20) DEFAULT 'present',
created_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
db.session.execute(text(create_table_sql))
print("✅ Created attendance_data table")
# Create indexes for better performance
indexes_sql = [
"CREATE INDEX IF NOT EXISTS idx_attendance_qr_code ON attendance_data(qr_code_id);",
"CREATE INDEX IF NOT EXISTS idx_attendance_employee ON attendance_data(employee_id);",
"CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance_data(check_in_date);",
"CREATE INDEX IF NOT EXISTS idx_attendance_timestamp ON attendance_data(created_timestamp);"
]
for index_sql in indexes_sql:
db.session.execute(text(index_sql))
print("✅ Created database indexes")
# Add QR code URL field to qr_codes table if it doesn't exist
add_url_field_sql = """
ALTER TABLE qr_codes
ADD COLUMN IF NOT EXISTS qr_url VARCHAR(255) UNIQUE;
"""
db.session.execute(text(add_url_field_sql))
print("✅ Added qr_url field to qr_codes table")
# Update existing QR codes with unique URLs
update_urls_sql = """
UPDATE qr_codes
SET qr_url = CONCAT('qr-', id, '-', LOWER(REPLACE(REPLACE(name, ' ', '-'), '''', '')))
WHERE qr_url IS NULL;
"""
db.session.execute(text(update_urls_sql))
print("✅ Generated URLs for existing QR codes")
# Create a view for attendance reporting
create_view_sql = """
CREATE OR REPLACE VIEW attendance_report AS
SELECT
ad.id,
ad.employee_id,
ad.check_in_date,
ad.check_in_time,
ad.device_info,
ad.location_name,
ad.status,
ad.created_timestamp,
qc.name as qr_code_name,
qc.location as qr_location,
qc.location_event,
qc.location_address,
u.full_name as qr_creator_name
FROM attendance_data ad
JOIN qr_codes qc ON ad.qr_code_id = qc.id
JOIN users u ON qc.created_by = u.id
ORDER BY ad.created_timestamp DESC;
"""
db.session.execute(text(create_view_sql))
print("✅ Created attendance_report view")
# Commit all changes
db.session.commit()
print("\n🎉 Database update completed successfully!")
print("\n📋 Changes Summary:")
print(" - Added attendance_data table")
print(" - Created performance indexes")
print(" - Added qr_url field to qr_codes")
print(" - Generated URLs for existing QR codes")
print(" - Created attendance_report view")
except Exception as e:
db.session.rollback()
print(f"❌ Error during database update: {str(e)}")
print("\n🔧 Troubleshooting tips:")
print(" 1. Ensure PostgreSQL is running")
print(" 2. Check database connection string")
print(" 3. Verify database user has CREATE permissions")
sys.exit(1)
def create_sample_attendance():
"""Create sample attendance data for testing"""
print("\n📦 Creating sample attendance data...")
app = create_app()
db = SQLAlchemy(app)
with app.app_context():
try:
# Get existing QR codes
qr_codes_result = db.session.execute(text("SELECT id, name FROM qr_codes WHERE active_status = true LIMIT 3"))
qr_codes = qr_codes_result.fetchall()
if not qr_codes:
print(" ⚠️ No active QR codes found, skipping sample data creation")
return
# Sample attendance data
sample_data = [
{
'employee_id': 'EMP001',
'device_info': 'iPhone 14 Pro - iOS 16.5',
'status': 'present'
},
{
'employee_id': 'EMP002',
'device_info': 'Samsung Galaxy S23 - Android 13',
'status': 'present'
},
{
'employee_id': 'EMP003',
'device_info': 'iPad Air - iOS 16.5',
'status': 'present'
}
]
for i, qr_code in enumerate(qr_codes):
if i < len(sample_data):
sample = sample_data[i]
insert_sql = """
INSERT INTO attendance_data
(qr_code_id, employee_id, check_in_date, check_in_time, device_info, location_name, status)
VALUES (:qr_id, :emp_id, CURRENT_DATE, CURRENT_TIME, :device, :location, :status)
"""
db.session.execute(text(insert_sql), {
'qr_id': qr_code[0],
'emp_id': sample['employee_id'],
'device': sample['device_info'],
'location': qr_code[1],
'status': sample['status']
})
db.session.commit()
print(f" ✅ Created {len(sample_data)} sample attendance records")
except Exception as e:
db.session.rollback()
print(f" ❌ Failed to create sample data: {str(e)}")
def verify_database():
"""Verify database changes were applied correctly"""
print("\n🔍 Verifying database changes...")
app = create_app()
db = SQLAlchemy(app)
with app.app_context():
try:
# Check if attendance_data table exists
table_check = db.session.execute(text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'attendance_data'
);
"""))
if table_check.fetchone()[0]:
print(" ✅ attendance_data table exists")
# Count records
count_result = db.session.execute(text("SELECT COUNT(*) FROM attendance_data"))
record_count = count_result.fetchone()[0]
print(f" 📊 Found {record_count} attendance records")
else:
print(" ❌ attendance_data table not found")
return False
# Check if qr_url field was added
field_check = db.session.execute(text("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'qr_codes' AND column_name = 'qr_url'
);
"""))
if field_check.fetchone()[0]:
print(" ✅ qr_url field added to qr_codes table")
# Check how many QR codes have URLs
url_count = db.session.execute(text("SELECT COUNT(*) FROM qr_codes WHERE qr_url IS NOT NULL"))
url_records = url_count.fetchone()[0]
print(f" 📊 {url_records} QR codes have generated URLs")
else:
print(" ❌ qr_url field not found in qr_codes table")
return False
# Check if view exists
view_check = db.session.execute(text("""
SELECT EXISTS (
SELECT FROM information_schema.views
WHERE table_name = 'attendance_report'
);
"""))
if view_check.fetchone()[0]:
print(" ✅ attendance_report view created")
else:
print(" ❌ attendance_report view not found")
return False
print("\n✅ All database changes verified successfully!")
return True
except Exception as e:
print(f" ❌ Verification failed: {str(e)}")
return False
def rollback_changes():
"""Rollback database changes if needed"""
print("\n⚠️ WARNING: This will remove all attendance tracking functionality!")
confirm = input("Are you sure you want to rollback? Type 'ROLLBACK' to confirm: ")
if confirm != 'ROLLBACK':
print("❌ Rollback cancelled")
return
app = create_app()
db = SQLAlchemy(app)
with app.app_context():
try:
# Drop view
db.session.execute(text("DROP VIEW IF EXISTS attendance_report"))
print(" ✅ Dropped attendance_report view")
# Drop table
db.session.execute(text("DROP TABLE IF EXISTS attendance_data"))
print(" ✅ Dropped attendance_data table")
# Remove qr_url column
db.session.execute(text("ALTER TABLE qr_codes DROP COLUMN IF EXISTS qr_url"))
print(" ✅ Removed qr_url column")
db.session.commit()
print("\n✅ Rollback completed successfully!")
except Exception as e:
db.session.rollback()
print(f"❌ Error during rollback: {str(e)}")
def main():
"""Main script entry point"""
if len(sys.argv) > 1:
command = sys.argv[1].lower()
if command == 'update':
add_attendance_table()
# Ask if user wants sample data
create_sample = input("\n❓ Would you like to create sample attendance data? (y/N): ").lower().strip()
if create_sample in ['y', 'yes']:
create_sample_attendance()
verify_database()
elif command == 'verify':
verify_database()
elif command == 'rollback':
rollback_changes()
elif command == 'sample':
create_sample_attendance()
else:
print("❌ Unknown command. Available commands:")
print(" update - Add attendance tracking to database")
print(" verify - Verify database changes")
print(" rollback - Remove attendance tracking (DANGEROUS)")
print(" sample - Create sample attendance data")
else:
# Default action is update
add_attendance_table()
verify_database()
if __name__ == '__main__':
main()
+57
View File
@@ -0,0 +1,57 @@
# QR Code Management System - Python Dependencies
# Production-ready Flask application with PostgreSQL support
# Core Flask Framework
Flask==2.3.3
Flask-SQLAlchemy==3.0.5
# Database Support
psycopg2-binary==2.9.7 # PostgreSQL adapter
SQLAlchemy==2.0.21
# Security and Authentication
Werkzeug==2.3.7 # Security utilities and password hashing
# QR Code Generation
qrcode==7.4.2 # QR code generation library
Pillow==10.0.1 # Image processing for QR codes
# User Agent Detection (NEW)
user-agents==2.2.0 # Device and browser detection from user agent strings
# URL and Regex Processing
regex==2023.8.8 # Enhanced regex support for URL generation
# Environment and Configuration
python-dotenv==1.0.0 # Environment variable management
# Date and Time Processing
python-dateutil==2.8.2 # Extended date/time processing
# Development and Testing (optional)
pytest==7.4.2 # Testing framework
pytest-flask==1.2.0 # Flask testing utilities
Flask-Testing==0.8.1 # Additional Flask testing tools
# Production Server (optional)
gunicorn==21.2.0 # WSGI HTTP Server for production
gevent==23.7.0 # Async worker support
# Utilities
click==8.1.7 # Command line interface creation
itsdangerous==2.1.2 # Secure data serialization
Jinja2==3.1.2 # Template engine
MarkupSafe==2.1.3 # Safe string handling
# Data Export and Processing (NEW)
openpyxl==3.1.2 # Excel file generation for attendance reports
pandas==2.1.1 # Data manipulation for reports (optional)
# HTTP Requests (for potential integrations)
requests==2.31.0 # HTTP library for external API calls
# Caching (optional for performance)
Flask-Caching==2.1.0 # Caching support for Flask
# Logging and Monitoring (optional)
python-json-logger==2.0.7 # Structured logging support
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""
Database Setup Script for QR Code Management System
Handles database initialization, migrations, and sample data creation
"""
import os
import sys
from datetime import datetime
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash
def create_app():
"""Create Flask application for database setup"""
app = Flask(__name__)
# Database configuration
database_url = os.environ.get('DATABASE_URL', 'postgresql://postgres:Ratkhonho123@localhost/qr_management')
app.config['SQLALCHEMY_DATABASE_URI'] = database_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'setup-key-change-in-production'
return app
def create_models(db):
"""Define database models"""
# User Model
class User(db.Model):
"""
User model to manage system users with role-based access control
"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
full_name = db.Column(db.String(100), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), nullable=False, default='staff') # admin or staff
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
created_date = db.Column(db.DateTime, default=datetime.utcnow)
active_status = db.Column(db.Boolean, default=True)
last_login_date = db.Column(db.DateTime, nullable=True)
# Relationships
created_users = db.relationship('User', backref=db.backref('creator', remote_side=[id]))
created_qr_codes = db.relationship('QRCode', backref='creator', lazy='dynamic')
def set_password(self, password):
"""Hash and set user password"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Verify user password"""
from werkzeug.security import check_password_hash
return check_password_hash(self.password_hash, password)
def is_admin(self):
"""Check if user has admin privileges"""
return self.role == 'admin'
# QR Code Model
class QRCode(db.Model):
"""
QR Code model to manage QR code records and metadata
"""
__tablename__ = 'qr_codes'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
location = db.Column(db.String(100), nullable=False)
location_address = db.Column(db.Text, nullable=False)
location_event = db.Column(db.String(200), nullable=False)
qr_code_image = db.Column(db.Text, nullable=False) # Base64 encoded image
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_date = db.Column(db.DateTime, default=datetime.utcnow)
active_status = db.Column(db.Boolean, default=True)
return User, QRCode
def setup_database():
"""
Complete database setup including:
- Table creation
- Default admin user
- Sample data (optional)
"""
print("🚀 Starting QR Code Management System Database Setup...")
print("=" * 60)
# Create Flask app and initialize database
app = create_app()
db = SQLAlchemy(app)
with app.app_context():
try:
# Create models
User, QRCode = create_models(db)
print("📊 Creating database tables...")
db.create_all()
print("✅ Database tables created successfully!")
# Create default admin user
print("\n👤 Creating default admin user...")
create_default_admin(db, User)
# Ask if user wants sample data
create_sample = input("\n❓ Would you like to create sample QR codes? (y/N): ").lower().strip()
if create_sample in ['y', 'yes']:
create_sample_data(db, User, QRCode)
print("\n🎉 Database setup completed successfully!")
print("\n📋 Setup Summary:")
print(" - Database tables created")
print(" - Default admin user created")
print(" - Login credentials: admin / admin123")
print(" - ⚠️ IMPORTANT: Change the default password after first login!")
if create_sample in ['y', 'yes']:
print(" - Sample QR codes created")
print("\n🚀 You can now start the application with: python app.py")
except Exception as e:
print(f"❌ Error during database setup: {str(e)}")
print("\n🔧 Troubleshooting tips:")
print(" 1. Make sure PostgreSQL is running")
print(" 2. Check your database connection string")
print(" 3. Ensure the database exists")
print(" 4. Verify database user permissions")
sys.exit(1)
def create_default_admin(db, User):
"""Create default admin user if it doesn't exist"""
# Check if admin user already exists
admin = User.query.filter_by(username='admin').first()
if admin:
print(" ️ Admin user already exists, skipping creation")
return
try:
# Create default admin user
admin_user = User(
full_name='System Administrator',
email='admin@qrmanager.local',
username='admin',
role='admin',
active_status=True,
created_date=datetime.utcnow()
)
admin_user.set_password('admin123') # Default password - should be changed
db.session.add(admin_user)
db.session.commit()
print(" ✅ Default admin user created successfully!")
print(" 📧 Email: admin@qrmanager.local")
print(" 👤 Username: admin")
print(" 🔑 Password: admin123")
except Exception as e:
print(f" ❌ Failed to create admin user: {str(e)}")
db.session.rollback()
raise
def create_sample_data(db, User, QRCode):
"""Create sample QR codes for demonstration"""
print("\n📦 Creating sample data...")
# Get admin user
admin = User.query.filter_by(username='admin').first()
if not admin:
print(" ❌ Admin user not found, cannot create sample data")
return
# Sample QR codes data
sample_qr_codes = [
{
'name': 'Conference Room A Check-in',
'location': 'Conference Room A',
'location_address': '123 Business Plaza, Suite 400, New York, NY 10001',
'location_event': 'Weekly Team Meeting',
},
{
'name': 'Main Lobby Registration',
'location': 'Main Lobby',
'location_address': '456 Corporate Center, Ground Floor, Chicago, IL 60601',
'location_event': 'Annual Company Conference 2025',
},
{
'name': 'Training Room B',
'location': 'Training Room B',
'location_address': '789 Innovation Hub, 2nd Floor, San Francisco, CA 94105',
'location_event': 'Employee Onboarding Session',
},
{
'name': 'Customer Service Desk',
'location': 'Customer Service Counter',
'location_address': '321 Service Plaza, Main Floor, Austin, TX 78701',
'location_event': 'Customer Support Check-in',
},
{
'name': 'Cafeteria Feedback Station',
'location': 'Employee Cafeteria',
'location_address': '654 Office Complex, Lower Level, Seattle, WA 98101',
'location_event': 'Meal Feedback Collection',
}
]
try:
# Import QR code generation function
import qrcode
import io
import base64
for qr_data in sample_qr_codes:
# Generate QR code image
qr_content = f"Event: {qr_data['location_event']}\nLocation: {qr_data['location']}\nAddress: {qr_data['location_address']}"
# Create QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(qr_content)
qr.make(fit=True)
# Generate image
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_str = base64.b64encode(buffer.getvalue()).decode()
# Create QR code record
qr_code = QRCode(
name=qr_data['name'],
location=qr_data['location'],
location_address=qr_data['location_address'],
location_event=qr_data['location_event'],
qr_code_image=img_str,
created_by=admin.id,
created_date=datetime.utcnow(),
active_status=True
)
db.session.add(qr_code)
db.session.commit()
print(f" ✅ Created {len(sample_qr_codes)} sample QR codes!")
except Exception as e:
print(f" ❌ Failed to create sample data: {str(e)}")
db.session.rollback()
raise
def reset_database():
"""Reset database (drop all tables and recreate)"""
print("⚠️ WARNING: This will delete ALL data in the database!")
confirm = input("Are you sure you want to reset the database? Type 'RESET' to confirm: ")
if confirm != 'RESET':
print("❌ Database reset cancelled")
return
app = create_app()
db = SQLAlchemy(app)
with app.app_context():
try:
# Create models
User, QRCode = create_models(db)
print("🗑️ Dropping all tables...")
db.drop_all()
print("📊 Recreating tables...")
db.create_all()
print("✅ Database reset completed!")
except Exception as e:
print(f"❌ Error during database reset: {str(e)}")
sys.exit(1)
def check_database_connection():
"""Test database connectivity"""
print("🔍 Testing database connection...")
app = create_app()
db = SQLAlchemy(app)
with app.app_context():
try:
# Create models
User, QRCode = create_models(db)
# Try to execute a simple query
db.session.execute('SELECT 1').fetchone()
print("✅ Database connection successful!")
# Check if tables exist
from sqlalchemy import inspect
inspector = inspect(db.engine)
tables = inspector.get_table_names()
if 'users' in tables and 'qr_codes' in tables:
print("✅ Required tables exist")
# Count records
user_count = User.query.count()
qr_count = QRCode.query.count()
print(f"📊 Database contains: {user_count} users, {qr_count} QR codes")
else:
print("⚠️ Some tables are missing - run setup to create them")
except Exception as e:
print(f"❌ Database connection failed: {str(e)}")
print("\n🔧 Check your database configuration:")
print(f" Database URL: {app.config['SQLALCHEMY_DATABASE_URI']}")
sys.exit(1)
def main():
"""Main setup script entry point"""
if len(sys.argv) > 1:
command = sys.argv[1].lower()
if command == 'reset':
reset_database()
elif command == 'check':
check_database_connection()
elif command == 'setup':
setup_database()
else:
print("❌ Unknown command. Available commands:")
print(" setup - Set up database and create default data")
print(" reset - Reset database (delete all data)")
print(" check - Check database connection and status")
else:
# Default action is setup
setup_database()
if __name__ == '__main__':
main()
+807
View File
@@ -0,0 +1,807 @@
/**
* Dashboard-specific styles
* static/css/dashboard.css
*/
/* Dashboard Header */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-8);
padding: var(--spacing-6);
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.dashboard-header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(
90deg,
var(--primary-color),
var(--primary-hover)
);
}
.welcome-section h1 {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--gray-900);
margin-bottom: var(--spacing-2);
}
.welcome-section p {
color: var(--gray-500);
font-size: var(--font-size-lg);
}
.user-info-badge {
display: flex;
gap: var(--spacing-4);
flex-wrap: wrap;
margin-top: var(--spacing-4);
}
.role-indicator {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: capitalize;
}
.role-indicator.admin {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.role-indicator.staff {
background: rgba(100, 116, 139, 0.1);
color: var(--gray-600);
border: 1px solid rgba(100, 116, 139, 0.3);
}
.last-login {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
background: rgba(37, 99, 235, 0.1);
color: var(--primary-color);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
border: 1px solid rgba(37, 99, 235, 0.3);
}
.quick-actions {
display: flex;
gap: var(--spacing-3);
flex-wrap: wrap;
}
/* Statistics Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.stat-card {
background: var(--white);
padding: var(--spacing-6);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: var(--spacing-4);
transition: var(--transition);
border: 1px solid var(--gray-200);
position: relative;
overflow: hidden;
}
.stat-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(
90deg,
var(--primary-color),
var(--primary-hover)
);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-xl);
}
.stat-icon {
width: 70px;
height: 70px;
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
color: var(--white);
flex-shrink: 0;
}
.stat-card.primary .stat-icon {
background: linear-gradient(
135deg,
var(--primary-color),
var(--primary-hover)
);
}
.stat-card.success .stat-icon {
background: linear-gradient(135deg, var(--success-color), #047857);
}
.stat-card.warning .stat-icon {
background: linear-gradient(135deg, var(--warning-color), #b45309);
}
.stat-card.danger .stat-icon {
background: linear-gradient(135deg, var(--danger-color), #b91c1c);
}
.stat-content h3 {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--gray-900);
margin-bottom: var(--spacing-1);
}
.stat-content p {
color: var(--gray-500);
font-size: var(--font-size-sm);
margin: 0;
}
/* Section Headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-6);
padding: var(--spacing-4) 0;
}
.section-header h2 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--gray-900);
margin: 0;
}
.section-controls {
display: flex;
gap: var(--spacing-3);
align-items: center;
flex-wrap: wrap;
}
.search-box {
position: relative;
display: flex;
align-items: center;
}
.search-box i {
position: absolute;
left: var(--spacing-3);
color: var(--gray-400);
z-index: 1;
}
.search-box input {
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-10);
border: 2px solid var(--gray-200);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
width: 300px;
transition: var(--transition);
}
.search-box input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.filter-select {
padding: var(--spacing-3) var(--spacing-4);
border: 2px solid var(--gray-200);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
background-color: var(--white);
cursor: pointer;
transition: var(--transition);
min-width: 150px;
}
.filter-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* QR Codes Grid */
.qr-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
/* Enhanced QR Item Styling */
.qr-item {
border: 1px solid var(--gray-200);
border-radius: var(--radius-xl);
background: var(--white);
transition: var(--transition);
overflow: hidden;
opacity: 1;
transform: translateY(0);
cursor: pointer;
}
.qr-item:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.1);
transform: translateY(-2px);
}
.qr-item[data-status="inactive"] {
opacity: 0.7;
background: linear-gradient(135deg, var(--white), #f8fafc);
}
.qr-item[data-status="inactive"]:hover {
opacity: 0.9;
}
.qr-item.expanded {
border-color: var(--primary-color);
box-shadow: 0 8px 25px rgba(37, 99, 235, 0.15);
}
.qr-item-header {
padding: var(--spacing-4);
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
}
.qr-code-preview {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--radius-lg);
overflow: hidden;
border: 2px solid var(--gray-200);
transition: var(--transition);
}
.qr-code-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.qr-item:hover .qr-code-preview {
border-color: var(--primary-color);
}
.qr-item-info {
flex: 1;
min-width: 0;
}
.qr-item-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--spacing-2);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.qr-item-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.qr-item-meta span {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--gray-500);
}
.qr-status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 600;
transition: var(--transition);
}
.qr-status.active {
background: rgba(5, 150, 105, 0.1);
color: var(--success-color);
border: 1px solid rgba(5, 150, 105, 0.2);
}
.qr-status.inactive {
background: rgba(220, 38, 38, 0.1);
color: var(--danger-color);
border: 1px solid rgba(220, 38, 38, 0.2);
}
.qr-item-actions {
padding: 0 var(--spacing-4) var(--spacing-4);
}
.quick-actions {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.action-btn {
padding: var(--spacing-2);
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
text-decoration: none;
}
.btn-activate {
background: rgba(5, 150, 105, 0.1);
color: var(--success-color);
border-color: rgba(5, 150, 105, 0.2);
}
.btn-activate:hover {
background: var(--success-color);
color: var(--white);
}
.btn-deactivate {
background: rgba(245, 158, 11, 0.1);
color: var(--warning-color);
border-color: rgba(245, 158, 11, 0.2);
}
.btn-deactivate:hover {
background: var(--warning-color);
color: var(--white);
}
.btn-download {
background: rgba(37, 99, 235, 0.1);
color: var(--primary-color);
border-color: rgba(37, 99, 235, 0.2);
}
.btn-download:hover {
background: var(--primary-color);
color: var(--white);
}
.btn-edit {
background: rgba(107, 114, 128, 0.1);
color: var(--gray-600);
border-color: rgba(107, 114, 128, 0.2);
}
.btn-edit:hover {
background: var(--gray-600);
color: var(--white);
}
.btn-delete {
background: rgba(220, 38, 38, 0.1);
color: var(--danger-color);
border-color: rgba(220, 38, 38, 0.2);
}
.btn-delete:hover {
background: var(--danger-color);
color: var(--white);
}
/* Expanded QR Item Details */
.qr-item-details {
padding: var(--spacing-4);
border-top: 1px solid var(--gray-200);
background: var(--gray-50);
display: none;
}
.qr-item.expanded .qr-item-details {
display: block;
animation: slideDown 0.3s ease-out;
}
.qr-details-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
}
.qr-details-info {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.info-item {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.info-item .label {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-item .value {
font-size: var(--font-size-sm);
color: var(--gray-900);
}
.qr-details-preview {
display: flex;
justify-content: center;
align-items: center;
}
.qr-details-image {
width: 150px;
height: 150px;
border-radius: var(--radius-lg);
border: 2px solid var(--gray-200);
overflow: hidden;
cursor: pointer;
transition: var(--transition);
}
.qr-details-image:hover {
border-color: var(--primary-color);
transform: scale(1.02);
}
.qr-details-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.details-actions {
display: flex;
gap: var(--spacing-3);
margin-top: var(--spacing-4);
flex-wrap: wrap;
}
.details-actions .btn {
flex: 1;
min-width: 150px;
}
/* Bulk Actions Bar */
.bulk-actions-bar {
position: fixed;
bottom: var(--spacing-4);
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
padding: var(--spacing-4) var(--spacing-6);
display: flex;
align-items: center;
gap: var(--spacing-4);
z-index: var(--z-dropdown);
opacity: 0;
transition: all 0.3s ease-out;
}
.bulk-actions-bar.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.bulk-actions-info {
font-size: var(--font-size-sm);
color: var(--gray-600);
}
.bulk-actions-buttons {
display: flex;
gap: var(--spacing-2);
}
/* Status loading animation */
.status-loading {
opacity: 0.6;
pointer-events: none;
}
.status-loading i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Results counter */
.results-counter {
font-size: var(--font-size-sm);
color: var(--gray-600);
padding: var(--spacing-2) var(--spacing-4);
background: var(--gray-50);
border-radius: var(--radius);
border: 1px solid var(--gray-200);
}
/* Empty States */
.empty-state {
text-align: center;
padding: var(--spacing-16) var(--spacing-8);
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
}
.empty-icon {
width: 80px;
height: 80px;
margin: 0 auto var(--spacing-6);
background: var(--gray-100);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: var(--gray-400);
}
.empty-state h3 {
font-size: var(--font-size-xl);
color: var(--gray-700);
margin-bottom: var(--spacing-3);
}
.empty-state p {
color: var(--gray-500);
margin-bottom: var(--spacing-6);
}
.search-empty-state {
text-align: center;
padding: 3rem 2rem;
background: var(--white);
border-radius: var(--radius-xl);
border: 2px dashed var(--gray-200);
margin: var(--spacing-6) 0;
}
/* QR Modal Enhancements */
.qr-modal {
max-width: 600px;
}
.qr-modal-image {
text-align: center;
padding: var(--spacing-6);
}
.qr-modal-image img {
max-width: 100%;
height: auto;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
/* Animation classes */
.fade-in {
animation: fadeInUp 0.3s ease-out;
}
.fade-out {
animation: fadeOutDown 0.3s ease-out;
}
.animate-in {
animation: slideUp 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
}
/* Responsive Design for Dashboard */
@media (max-width: 1024px) {
.qr-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.qr-details-content {
grid-template-columns: 1fr;
gap: var(--spacing-4);
}
.search-box input {
width: 250px;
}
}
@media (max-width: 768px) {
.dashboard-header {
flex-direction: column;
gap: var(--spacing-4);
text-align: center;
}
.stats-grid {
grid-template-columns: 1fr;
gap: var(--spacing-4);
}
.section-header {
flex-direction: column;
gap: var(--spacing-4);
align-items: stretch;
}
.section-controls {
justify-content: center;
}
.search-box input {
width: 100%;
max-width: 300px;
}
.qr-grid {
grid-template-columns: 1fr;
}
.qr-item-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
.details-actions {
flex-direction: column;
}
.details-actions .btn {
min-width: auto;
}
.bulk-actions-bar {
left: var(--spacing-3);
right: var(--spacing-3);
transform: translateY(100px);
flex-direction: column;
gap: var(--spacing-3);
}
.bulk-actions-bar.show {
transform: translateY(0);
}
.bulk-actions-buttons {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.qr-code-preview {
width: 60px;
height: 60px;
}
.qr-item-title {
font-size: var(--font-size-base);
}
.action-btn {
width: 32px;
height: 32px;
padding: var(--spacing-1);
}
.qr-details-image {
width: 120px;
height: 120px;
}
.user-info-badge {
flex-direction: column;
align-items: center;
}
}
+845
View File
@@ -0,0 +1,845 @@
/* QR Destination Page Styles */
:root {
/* Color Palette */
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--success-color: #059669;
--warning-color: #d97706;
--danger-color: #dc2626;
--info-color: #0891b2;
/* Neutral Colors */
--white: #ffffff;
--gray-50: #f8fafc;
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
--gray-400: #94a3b8;
--gray-500: #64748b;
--gray-600: #475569;
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
/* Spacing and Layout */
--radius-sm: 0.25rem;
--radius: 0.375rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--transition: all 0.15s ease-in-out;
}
/* Reset and Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
min-height: 100vh;
color: var(--gray-900);
line-height: 1.6;
}
/* Container */
.destination-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem 1rem;
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* Header Section */
.destination-header {
text-align: center;
color: var(--white);
margin-bottom: 1rem;
}
.header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
font-size: 2rem;
margin-bottom: 1rem;
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.3);
}
.destination-header h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.location-info {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 1.125rem;
opacity: 0.9;
}
/* Card Styles */
.info-card,
.checkin-card,
.success-card {
background: var(--white);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xl);
overflow: hidden;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Info Card */
.info-header {
background: linear-gradient(135deg, var(--gray-50), var(--white));
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
}
.info-header h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-800);
}
.info-details {
padding: 1.5rem;
display: grid;
gap: 1rem;
}
.detail-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--gray-50);
border-radius: var(--radius-lg);
border: 1px solid var(--gray-200);
transition: var(--transition);
}
.detail-item:hover {
background: var(--gray-100);
transform: translateY(-1px);
}
.detail-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--primary-color);
color: var(--white);
border-radius: var(--radius-lg);
font-size: 1.125rem;
flex-shrink: 0;
}
.detail-content {
flex: 1;
min-width: 0;
}
.detail-content strong {
display: block;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.detail-content span {
color: var(--gray-900);
font-size: 1rem;
word-break: break-word;
}
/* Check-in Card */
.checkin-header {
background: linear-gradient(135deg, var(--success-color), #047857);
color: var(--white);
padding: 2rem 1.5rem;
text-align: center;
}
.checkin-header h2 {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.checkin-header p {
opacity: 0.9;
font-size: 1rem;
}
.checkin-form {
padding: 2rem 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 0.75rem;
font-size: 1rem;
}
.form-group input {
width: 100%;
padding: 1rem;
border: 2px solid var(--gray-200);
border-radius: var(--radius-lg);
font-size: 1.125rem;
transition: var(--transition);
background-color: var(--white);
text-align: center;
font-weight: 600;
text-transform: uppercase;
}
.form-group input:focus {
outline: none;
border-color: var(--success-color);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
transform: scale(1.02);
}
.form-group input::placeholder {
text-transform: none;
font-weight: normal;
color: var(--gray-400);
}
.form-help {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--gray-500);
text-align: center;
}
.form-actions {
margin-top: 2rem;
}
/* Button Styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 1.25rem 2rem;
border: none;
border-radius: var(--radius-xl);
font-size: 1.125rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
text-decoration: none;
position: relative;
overflow: hidden;
}
.btn-primary {
background: linear-gradient(135deg, var(--success-color), #047857);
color: var(--white);
box-shadow: var(--shadow-lg);
}
.btn-primary:hover {
background: linear-gradient(135deg, #047857, #065f46);
transform: translateY(-2px);
box-shadow: var(--shadow-xl);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-secondary {
background: var(--gray-600);
color: var(--white);
box-shadow: var(--shadow);
}
.btn-secondary:hover {
background: var(--gray-700);
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none !important;
}
.btn-content,
.btn-loader {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
/* Status Messages */
.status-message {
margin: 1rem 1.5rem;
padding: 1rem;
border-radius: var(--radius-lg);
font-weight: 500;
text-align: center;
}
.status-message.success {
background: rgba(5, 150, 105, 0.1);
color: var(--success-color);
border: 1px solid rgba(5, 150, 105, 0.3);
}
.status-message.error {
background: rgba(220, 38, 38, 0.1);
color: var(--danger-color);
border: 1px solid rgba(220, 38, 38, 0.3);
}
.status-message.warning {
background: rgba(217, 119, 6, 0.1);
color: var(--warning-color);
border: 1px solid rgba(217, 119, 6, 0.3);
}
/* Success Card */
.success-card {
text-align: center;
padding: 2rem 1.5rem;
background: linear-gradient(135deg, rgba(5, 150, 105, 0.05), rgba(5, 150, 105, 0.1));
border: 2px solid var(--success-color);
}
.success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
background: var(--success-color);
color: var(--white);
border-radius: 50%;
font-size: 3rem;
margin-bottom: 1.5rem;
animation: successPulse 2s infinite;
}
@keyframes successPulse {
0% { box-shadow: 0 0 0 0 rgba(5, 150, 105, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(5, 150, 105, 0); }
100% { box-shadow: 0 0 0 0 rgba(5, 150, 105, 0); }
}
.success-card h2 {
font-size: 2rem;
font-weight: 700;
color: var(--success-color);
margin-bottom: 2rem;
}
.success-details {
display: grid;
gap: 1rem;
margin-bottom: 2rem;
text-align: left;
}
.success-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--white);
border-radius: var(--radius-lg);
border: 1px solid var(--gray-200);
}
.success-item strong {
color: var(--gray-700);
font-weight: 600;
}
.success-item span {
color: var(--gray-900);
font-weight: 500;
}
.success-actions {
margin-top: 2rem;
}
/* Error Card */
.error-card {
background: var(--white);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xl);
padding: 3rem 2rem;
text-align: center;
animation: slideUp 0.6s ease-out;
}
.error-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
background: rgba(217, 119, 6, 0.1);
color: var(--warning-color);
border-radius: 50%;
font-size: 3rem;
margin-bottom: 2rem;
}
.error-card h1 {
font-size: 2rem;
font-weight: 700;
color: var(--gray-900);
margin-bottom: 1rem;
}
.error-message {
margin-bottom: 2rem;
}
.error-message p {
color: var(--gray-600);
font-size: 1.125rem;
margin-bottom: 1.5rem;
}
.possible-reasons {
text-align: left;
background: var(--gray-50);
padding: 1.5rem;
border-radius: var(--radius-lg);
border: 1px solid var(--gray-200);
}
.possible-reasons h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 1rem;
}
.possible-reasons ul {
list-style: none;
padding: 0;
margin: 0;
}
.possible-reasons li {
padding: 0.5rem 0;
color: var(--gray-600);
position: relative;
padding-left: 1.5rem;
}
.possible-reasons li::before {
content: "•";
position: absolute;
left: 0;
color: var(--warning-color);
font-weight: bold;
}
.error-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 2rem;
}
.help-section {
padding: 1.5rem;
background: var(--gray-50);
border-radius: var(--radius-lg);
border: 1px solid var(--gray-200);
}
.help-section p {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--gray-600);
font-size: 0.875rem;
margin: 0;
}
/* Footer */
.destination-footer {
text-align: center;
color: rgba(255, 255, 255, 0.8);
margin-top: auto;
padding-top: 2rem;
}
.destination-footer p {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.destination-footer small {
opacity: 0.7;
font-size: 0.875rem;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-spinner {
background: var(--white);
padding: 2rem;
border-radius: var(--radius-xl);
text-align: center;
box-shadow: var(--shadow-xl);
}
.loading-spinner i {
font-size: 2rem;
color: var(--primary-color);
margin-bottom: 1rem;
}
.loading-spinner p {
color: var(--gray-700);
font-weight: 500;
margin: 0;
}
/* Form Validation States */
.form-group input.error {
border-color: var(--danger-color);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
.form-group input.success {
border-color: var(--success-color);
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23059669'%3e%3cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1rem;
}
/* Animations */
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.shake {
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* Responsive Design */
@media (max-width: 768px) {
.destination-container {
padding: 1rem 0.5rem;
gap: 1.5rem;
}
.destination-header h1 {
font-size: 2rem;
}
.header-icon {
width: 60px;
height: 60px;
font-size: 1.5rem;
}
.info-card,
.checkin-card,
.success-card,
.error-card {
margin: 0 0.5rem;
}
.checkin-form {
padding: 1.5rem 1rem;
}
.detail-item {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
.detail-icon {
align-self: center;
}
.error-actions {
grid-template-columns: 1fr;
}
.success-details {
text-align: center;
}
.success-item {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
}
@media (max-width: 480px) {
.destination-container {
padding: 0.5rem;
}
.destination-header h1 {
font-size: 1.75rem;
}
.info-details,
.success-details {
gap: 0.75rem;
}
.form-group input {
font-size: 1rem;
padding: 0.875rem;
}
.btn {
padding: 1rem 1.5rem;
font-size: 1rem;
}
.error-card {
padding: 2rem 1rem;
}
.success-icon,
.error-icon {
width: 80px;
height: 80px;
font-size: 2.5rem;
}
}
/* High Contrast Mode */
@media (prefers-contrast: high) {
.info-card,
.checkin-card,
.success-card,
.error-card {
border: 2px solid var(--gray-900);
}
.detail-item {
border: 1px solid var(--gray-900);
}
.form-group input {
border: 2px solid var(--gray-900);
}
.btn-primary {
background: var(--gray-900);
border: 2px solid var(--white);
}
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.success-icon {
animation: none;
}
.btn:hover,
.detail-item:hover {
transform: none;
}
}
/* Print Styles */
@media print {
body {
background: white;
color: black;
}
.destination-container {
max-width: none;
padding: 1rem;
}
.info-card,
.checkin-card,
.success-card {
box-shadow: none;
border: 1px solid #000;
break-inside: avoid;
}
.btn,
.loading-overlay {
display: none;
}
.destination-header {
color: black;
}
.header-icon {
background: white;
border: 2px solid black;
color: black;
}
}
/* Dark Mode Support */
@media (prefers-color-scheme: dark) {
:root {
--white: #1a1a1a;
--gray-50: #262626;
--gray-100: #2d2d2d;
--gray-200: #404040;
--gray-300: #525252;
--gray-400: #737373;
--gray-500: #a3a3a3;
--gray-600: #d4d4d4;
--gray-700: #e5e5e5;
--gray-800: #f5f5f5;
--gray-900: #ffffff;
}
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--gray-100);
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
/* Selection Styles */
::selection {
background: rgba(37, 99, 235, 0.2);
color: var(--gray-900);
}
/* Focus Styles for Accessibility */
.btn:focus,
input:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* Utility Classes */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.hidden { display: none !important; }
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
+1056
View File
File diff suppressed because it is too large Load Diff
+814
View File
@@ -0,0 +1,814 @@
/**
* Users management specific styles
* static/css/users.css
*/
/* Users Page Header */
.users-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-8);
padding: var(--spacing-6);
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.users-header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #059669, #047857);
}
.users-header h1 {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--gray-900);
margin-bottom: var(--spacing-2);
}
.users-header p {
color: var(--gray-500);
font-size: var(--font-size-lg);
margin: 0;
}
/* User Statistics */
.user-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.stat-card {
background: var(--white);
padding: var(--spacing-6);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: var(--spacing-4);
transition: var(--transition);
border: 1px solid var(--gray-200);
position: relative;
overflow: hidden;
}
.stat-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.stat-card.admin::before {
background: linear-gradient(90deg, #fbbf24, #f59e0b);
}
.stat-card.staff::before {
background: linear-gradient(90deg, var(--gray-500), var(--gray-600));
}
.stat-card.active::before {
background: linear-gradient(90deg, var(--success-color), #047857);
}
.stat-card.inactive::before {
background: linear-gradient(90deg, var(--danger-color), #b91c1c);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: var(--white);
flex-shrink: 0;
}
.stat-icon.admin {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
}
.stat-icon.staff {
background: linear-gradient(135deg, var(--gray-500), var(--gray-600));
}
.stat-icon.active {
background: linear-gradient(135deg, var(--success-color), #047857);
}
.stat-icon.inactive {
background: linear-gradient(135deg, var(--danger-color), #b91c1c);
}
.stat-info h3 {
font-size: 1.75rem;
font-weight: 700;
color: var(--gray-900);
margin-bottom: 0.25rem;
}
.stat-info p {
color: var(--gray-500);
font-size: var(--font-size-sm);
margin: 0;
}
/* Users Controls */
.users-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-6);
padding: var(--spacing-6);
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
flex-wrap: wrap;
gap: var(--spacing-4);
}
.search-filters {
display: flex;
gap: var(--spacing-4);
align-items: center;
flex-wrap: wrap;
}
.search-box {
position: relative;
display: flex;
align-items: center;
}
.search-box i {
position: absolute;
left: var(--spacing-3);
color: var(--gray-400);
z-index: 1;
}
.search-box input {
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-10);
border: 2px solid var(--gray-200);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
width: 300px;
transition: var(--transition);
}
.search-box input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.filter-group {
display: flex;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.filter-select {
padding: var(--spacing-3) var(--spacing-4);
border: 2px solid var(--gray-200);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
background-color: var(--white);
cursor: pointer;
transition: var(--transition);
min-width: 120px;
}
.filter-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.results-counter {
font-size: var(--font-size-sm);
color: var(--gray-600);
padding: var(--spacing-2) var(--spacing-4);
background: var(--gray-50);
border-radius: var(--radius);
border: 1px solid var(--gray-200);
}
/* Users Table */
.users-table-container {
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
overflow: hidden;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background-color: var(--gray-50);
padding: var(--spacing-4);
text-align: left;
font-weight: 600;
color: var(--gray-700);
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--gray-200);
position: sticky;
top: 0;
z-index: 10;
}
.users-table th:first-child {
width: 40px;
text-align: center;
}
.users-table td {
padding: var(--spacing-4);
border-bottom: 1px solid var(--gray-100);
vertical-align: middle;
}
.users-table tr.user-row {
transition: var(--transition);
cursor: pointer;
}
.users-table tr.user-row:hover {
background-color: var(--gray-50);
}
.users-table tr.user-row:last-child td {
border-bottom: none;
}
/* User Avatar */
.user-avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: linear-gradient(
135deg,
var(--primary-color),
var(--primary-hover)
);
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
margin-right: var(--spacing-3);
}
.user-info {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.user-details h4 {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--gray-900);
margin: 0 0 var(--spacing-1) 0;
}
.user-details p {
font-size: var(--font-size-xs);
color: var(--gray-500);
margin: 0;
}
/* User Role Badge */
.user-role {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.user-role.admin {
background-color: rgba(251, 191, 36, 0.1);
color: #d97706;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.user-role.staff {
background-color: rgba(100, 116, 139, 0.1);
color: var(--gray-600);
border: 1px solid rgba(100, 116, 139, 0.3);
}
/* User Status Badge */
.user-status {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 600;
}
.user-status.active {
background-color: rgba(5, 150, 105, 0.1);
color: var(--success-color);
border: 1px solid rgba(5, 150, 105, 0.2);
}
.user-status.inactive {
background-color: rgba(220, 38, 38, 0.1);
color: var(--danger-color);
border: 1px solid rgba(220, 38, 38, 0.2);
}
/* User Actions Dropdown */
.user-actions {
position: relative;
}
.dropdown-trigger {
background: none;
border: none;
cursor: pointer;
padding: var(--spacing-2);
border-radius: var(--radius);
transition: var(--transition);
color: var(--gray-500);
}
.dropdown-trigger:hover {
background-color: var(--gray-100);
color: var(--gray-700);
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
min-width: 200px;
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease-in-out;
}
.dropdown-menu.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.dropdown-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
width: 100%;
padding: var(--spacing-3) var(--spacing-4);
color: var(--gray-700);
text-decoration: none;
border: none;
background: none;
text-align: left;
cursor: pointer;
transition: var(--transition);
font-size: var(--font-size-sm);
}
.dropdown-item:hover {
background-color: var(--gray-50);
color: var(--gray-900);
text-decoration: none;
}
.dropdown-item.danger {
color: var(--danger-color);
}
.dropdown-item.danger:hover {
background-color: var(--danger-light);
color: var(--danger-color);
}
.dropdown-item.success {
color: var(--success-color);
}
.dropdown-item.success:hover {
background-color: var(--success-light);
color: var(--success-color);
}
.dropdown-item:first-child {
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
}
.dropdown-item:last-child {
border-bottom-left-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
}
.dropdown-divider {
height: 1px;
background-color: var(--gray-200);
margin: var(--spacing-1) 0;
}
/* Bulk Actions */
.bulk-actions-bar {
position: fixed;
bottom: var(--spacing-4);
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
padding: var(--spacing-4) var(--spacing-6);
display: flex;
align-items: center;
gap: var(--spacing-4);
z-index: var(--z-dropdown);
opacity: 0;
transition: all 0.3s ease-out;
min-width: 400px;
}
.bulk-actions-bar.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.bulk-actions-info {
font-size: var(--font-size-sm);
color: var(--gray-600);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.selected-count {
font-weight: 600;
color: var(--primary-color);
}
.bulk-actions-buttons {
display: flex;
gap: var(--spacing-2);
}
/* User Details Modal */
.user-details-modal {
max-width: 600px;
}
.user-profile-header {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-6);
padding: var(--spacing-6);
background: var(--gray-50);
border-radius: var(--radius-lg);
}
.user-profile-avatar {
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: linear-gradient(
135deg,
var(--primary-color),
var(--primary-hover)
);
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.5rem;
}
.user-profile-info h3 {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--gray-900);
margin-bottom: var(--spacing-1);
}
.user-profile-info p {
color: var(--gray-500);
margin: 0;
}
.user-details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-4);
margin-bottom: var(--spacing-6);
}
.detail-item {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.detail-label {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
font-size: var(--font-size-sm);
color: var(--gray-900);
}
/* Password Reset Modal */
.password-reset-form {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.password-strength {
margin-top: var(--spacing-2);
}
.strength-bar {
height: 4px;
background-color: var(--gray-200);
border-radius: var(--radius-full);
overflow: hidden;
}
.strength-fill {
height: 100%;
transition: var(--transition);
border-radius: var(--radius-full);
}
.strength-weak {
background-color: var(--danger-color);
width: 25%;
}
.strength-fair {
background-color: var(--warning-color);
width: 50%;
}
.strength-good {
background-color: #10b981;
width: 75%;
}
.strength-strong {
background-color: var(--success-color);
width: 100%;
}
.strength-text {
font-size: var(--font-size-xs);
margin-top: var(--spacing-1);
color: var(--gray-600);
}
/* Animation classes */
.fade-in {
animation: fadeInUp 0.3s ease-out;
}
.fade-out {
animation: fadeOutDown 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
}
.loading::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid var(--primary-color);
border-top: 2px solid transparent;
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Empty state */
.users-empty-state {
text-align: center;
padding: var(--spacing-16) var(--spacing-8);
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
}
.empty-icon {
width: 80px;
height: 80px;
margin: 0 auto var(--spacing-6);
background: var(--gray-100);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: var(--gray-400);
}
.users-empty-state h3 {
font-size: var(--font-size-xl);
color: var(--gray-700);
margin-bottom: var(--spacing-3);
}
.users-empty-state p {
color: var(--gray-500);
margin-bottom: var(--spacing-6);
}
/* Responsive Design */
@media (max-width: 1024px) {
.user-details-grid {
grid-template-columns: 1fr;
}
.search-box input {
width: 250px;
}
}
@media (max-width: 768px) {
.users-header {
flex-direction: column;
gap: var(--spacing-4);
text-align: center;
}
.user-stats {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-4);
}
.users-controls {
flex-direction: column;
align-items: stretch;
}
.search-filters {
justify-content: center;
}
.search-box input {
width: 100%;
max-width: 300px;
}
.filter-group {
justify-content: center;
}
/* Hide less important columns on mobile */
.users-table th:nth-child(4),
.users-table td:nth-child(4),
.users-table th:nth-child(5),
.users-table td:nth-child(5) {
display: none;
}
.user-profile-header {
flex-direction: column;
text-align: center;
}
.bulk-actions-bar {
left: var(--spacing-3);
right: var(--spacing-3);
transform: translateY(100px);
min-width: auto;
flex-direction: column;
gap: var(--spacing-3);
}
.bulk-actions-bar.show {
transform: translateY(0);
}
.bulk-actions-buttons {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.user-stats {
grid-template-columns: 1fr;
}
.users-table {
font-size: var(--font-size-xs);
}
.users-table th,
.users-table td {
padding: var(--spacing-2);
}
.user-avatar {
width: 32px;
height: 32px;
font-size: var(--font-size-xs);
}
.user-details h4 {
font-size: var(--font-size-xs);
}
.user-details p {
font-size: 0.625rem;
}
/* Hide checkbox column on very small screens */
.users-table th:first-child,
.users-table td:first-child {
display: none;
}
.bulk-actions-bar {
padding: var(--spacing-3);
}
}
+723
View File
@@ -0,0 +1,723 @@
/**
* Attendance Report JavaScript
* Handles filtering, sorting, pagination, and analytics for attendance data
*/
// Global variables
let currentPage = 1;
let entriesPerPage = 50;
let sortColumn = -1;
let sortDirection = 'asc';
let attendanceData = [];
let filteredData = [];
// Charts
let dailyChart = null;
let locationChart = null;
// Initialize page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('Attendance Report page initialized');
initializeReport();
loadAttendanceData();
initializeCharts();
setupEventListeners();
});
function initializeReport() {
// Load data from table
loadTableData();
// Initialize pagination
updatePagination();
// Apply initial filters if any
applyFilters();
}
function loadTableData() {
const table = document.getElementById('attendanceTable');
if (table) {
const rows = table.querySelectorAll('tbody tr');
attendanceData = Array.from(rows).map((row, index) => {
const cells = row.querySelectorAll('td');
return {
id: row.dataset.recordId,
index: index + 1,
employeeId: cells[1] ? cells[1].textContent.trim() : '',
location: cells[2] ? cells[2].textContent.trim() : '',
event: cells[3] ? cells[3].textContent.trim() : '',
date: cells[4] ? cells[4].textContent.trim() : '',
time: cells[5] ? cells[5].textContent.trim() : '',
device: cells[6] ? cells[6].getAttribute('title') || cells[6].textContent.trim() : '',
status: cells[7] ? cells[7].textContent.trim() : '',
element: row
};
});
filteredData = [...attendanceData];
}
}
function setupEventListeners() {
// Entries per page change
const entriesSelect = document.getElementById('entriesPerPage');
if (entriesSelect) {
entriesSelect.addEventListener('change', changeEntriesPerPage);
}
// Filter form
const filtersForm = document.getElementById('filtersForm');
if (filtersForm) {
filtersForm.addEventListener('submit', function(e) {
e.preventDefault();
applyFilters();
});
}
// Real-time employee filter
const employeeFilter = document.getElementById('employee');
if (employeeFilter) {
employeeFilter.addEventListener('input', debounce(applyFilters, 300));
}
// Date and location filters
const dateFilter = document.getElementById('date');
const locationFilter = document.getElementById('location');
if (dateFilter) {
dateFilter.addEventListener('change', applyFilters);
}
if (locationFilter) {
locationFilter.addEventListener('change', applyFilters);
}
}
function changeEntriesPerPage() {
const select = document.getElementById('entriesPerPage');
entriesPerPage = select.value === 'all' ? filteredData.length : parseInt(select.value);
currentPage = 1;
updateTable();
updatePagination();
}
function applyFilters() {
const dateFilter = document.getElementById('date')?.value || '';
const locationFilter = document.getElementById('location')?.value || '';
const employeeFilter = document.getElementById('employee')?.value.toLowerCase() || '';
filteredData = attendanceData.filter(record => {
const matchesDate = !dateFilter || record.date === dateFilter;
const matchesLocation = !locationFilter || record.location === locationFilter;
const matchesEmployee = !employeeFilter ||
record.employeeId.toLowerCase().includes(employeeFilter);
return matchesDate && matchesLocation && matchesEmployee;
});
currentPage = 1;
updateTable();
updatePagination();
updateFilterStats();
}
function clearFilters() {
// Clear form inputs
const form = document.getElementById('filtersForm');
if (form) {
form.reset();
}
// Reset filtered data
filteredData = [...attendanceData];
currentPage = 1;
// Update display
updateTable();
updatePagination();
updateFilterStats();
// Update URL without filters
const url = new URL(window.location);
url.search = '';
window.history.pushState({}, '', url);
}
function sortTable(columnIndex) {
const headers = ['index', 'employeeId', 'location', 'event', 'date', 'time', 'device', 'status'];
const column = headers[columnIndex];
if (sortColumn === columnIndex) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = columnIndex;
sortDirection = 'asc';
}
filteredData.sort((a, b) => {
let aVal = a[column];
let bVal = b[column];
// Handle different data types
if (column === 'date' || column === 'time') {
aVal = new Date(column === 'date' ? aVal : `2000-01-01 ${aVal}`);
bVal = new Date(column === 'date' ? bVal : `2000-01-01 ${bVal}`);
} else if (column === 'index') {
aVal = parseInt(aVal);
bVal = parseInt(bVal);
} else {
aVal = aVal.toString().toLowerCase();
bVal = bVal.toString().toLowerCase();
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
updateTable();
updateSortIndicators(columnIndex);
}
function updateSortIndicators(activeColumn) {
const headers = document.querySelectorAll('th[onclick]');
headers.forEach((header, index) => {
const icon = header.querySelector('i');
if (icon) {
if (index === activeColumn) {
icon.className = `fas fa-sort-${sortDirection === 'asc' ? 'up' : 'down'}`;
} else {
icon.className = 'fas fa-sort';
}
}
});
}
function updateTable() {
const tbody = document.querySelector('#attendanceTable tbody');
if (!tbody) return;
// Calculate pagination
const startIndex = (currentPage - 1) * entriesPerPage;
const endIndex = entriesPerPage === filteredData.length ?
filteredData.length :
Math.min(startIndex + entriesPerPage, filteredData.length);
// Hide all rows first
attendanceData.forEach(record => {
if (record.element) {
record.element.style.display = 'none';
}
});
// Show filtered and paginated rows
const visibleData = filteredData.slice(startIndex, endIndex);
visibleData.forEach((record, index) => {
if (record.element) {
record.element.style.display = '';
// Update row number
const firstCell = record.element.querySelector('td:first-child');
if (firstCell) {
firstCell.textContent = startIndex + index + 1;
}
}
});
// Show empty state if no data
showEmptyStateIfNeeded();
}
function showEmptyStateIfNeeded() {
const tbody = document.querySelector('#attendanceTable tbody');
let emptyRow = tbody.querySelector('.empty-row');
if (filteredData.length === 0) {
if (!emptyRow) {
emptyRow = document.createElement('tr');
emptyRow.className = 'empty-row';
emptyRow.innerHTML = `
<td colspan="9" style="text-align: center; padding: 3rem;">
<div style="color: #6b7280;">
<i class="fas fa-search" style="font-size: 2rem; margin-bottom: 1rem; opacity: 0.5;"></i>
<h3>No Records Found</h3>
<p>No attendance records match your current filters.</p>
<button onclick="clearFilters()" class="btn btn-primary" style="margin-top: 1rem;">
<i class="fas fa-refresh"></i> Clear Filters
</button>
</div>
</td>
`;
tbody.appendChild(emptyRow);
}
emptyRow.style.display = '';
} else if (emptyRow) {
emptyRow.style.display = 'none';
}
}
function updatePagination() {
const container = document.getElementById('paginationContainer');
if (!container) return;
const totalPages = Math.ceil(filteredData.length / entriesPerPage);
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let paginationHTML = '<div class="pagination">';
// Previous button
paginationHTML += `
<button onclick="goToPage(${currentPage - 1})"
class="pagination-btn"
${currentPage === 1 ? 'disabled' : ''}>
<i class="fas fa-chevron-left"></i>
</button>
`;
// Page numbers
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
if (startPage > 1) {
paginationHTML += `<button onclick="goToPage(1)" class="pagination-btn">1</button>`;
if (startPage > 2) {
paginationHTML += '<span class="pagination-ellipsis">...</span>';
}
}
for (let i = startPage; i <= endPage; i++) {
paginationHTML += `
<button onclick="goToPage(${i})"
class="pagination-btn ${i === currentPage ? 'active' : ''}">
${i}
</button>
`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
paginationHTML += '<span class="pagination-ellipsis">...</span>';
}
paginationHTML += `<button onclick="goToPage(${totalPages})" class="pagination-btn">${totalPages}</button>`;
}
// Next button
paginationHTML += `
<button onclick="goToPage(${currentPage + 1})"
class="pagination-btn"
${currentPage === totalPages ? 'disabled' : ''}>
<i class="fas fa-chevron-right"></i>
</button>
`;
paginationHTML += '</div>';
// Add pagination info
const startRecord = (currentPage - 1) * entriesPerPage + 1;
const endRecord = Math.min(currentPage * entriesPerPage, filteredData.length);
paginationHTML += `
<div class="pagination-info">
Showing ${startRecord} to ${endRecord} of ${filteredData.length} entries
${filteredData.length !== attendanceData.length ?
`(filtered from ${attendanceData.length} total entries)` : ''}
</div>
`;
container.innerHTML = paginationHTML;
}
function goToPage(page) {
const totalPages = Math.ceil(filteredData.length / entriesPerPage);
if (page < 1 || page > totalPages) return;
currentPage = page;
updateTable();
updatePagination();
// Scroll to top of table
const table = document.getElementById('attendanceTable');
if (table) {
table.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
function updateFilterStats() {
// Update stats display if needed
const totalRecords = filteredData.length;
console.log(`Filtered records: ${totalRecords}`);
}
// Record actions
function viewRecordDetails(recordId) {
const record = attendanceData.find(r => r.id == recordId);
if (!record) return;
const modal = document.getElementById('recordModal');
const modalTitle = document.getElementById('modalTitle');
const modalBody = document.getElementById('modalBody');
if (!modal || !modalTitle || !modalBody) return;
modalTitle.textContent = `Attendance Record - ${record.employeeId}`;
modalBody.innerHTML = `
<div class="record-details">
<div class="detail-grid">
<div class="detail-item">
<strong>Employee ID:</strong>
<span>${record.employeeId}</span>
</div>
<div class="detail-item">
<strong>Location:</strong>
<span>${record.location}</span>
</div>
<div class="detail-item">
<strong>Event:</strong>
<span>${record.event}</span>
</div>
<div class="detail-item">
<strong>Date:</strong>
<span>${record.date}</span>
</div>
<div class="detail-item">
<strong>Time:</strong>
<span>${record.time}</span>
</div>
<div class="detail-item">
<strong>Device:</strong>
<span>${record.device}</span>
</div>
<div class="detail-item">
<strong>Status:</strong>
<span class="status-badge ${record.status.toLowerCase()}">
${record.status}
</span>
</div>
</div>
</div>
`;
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('show'), 10);
}
function editRecord(recordId) {
// Placeholder for edit functionality
alert(`Edit functionality for record ${recordId} would be implemented here.`);
}
function deleteRecord(recordId) {
const record = attendanceData.find(r => r.id == recordId);
if (!record) return;
const confirmed = confirm(
`Are you sure you want to delete the attendance record for ${record.employeeId}?\n\n` +
`Date: ${record.date}\n` +
`Time: ${record.time}\n` +
`Location: ${record.location}\n\n` +
'This action cannot be undone.'
);
if (confirmed) {
// Here you would make an API call to delete the record
console.log(`Deleting record ${recordId}`);
// For demo purposes, just remove from current data
const index = attendanceData.findIndex(r => r.id == recordId);
if (index > -1) {
// Remove from DOM
if (attendanceData[index].element) {
attendanceData[index].element.remove();
}
// Remove from data arrays
attendanceData.splice(index, 1);
const filteredIndex = filteredData.findIndex(r => r.id == recordId);
if (filteredIndex > -1) {
filteredData.splice(filteredIndex, 1);
}
// Update display
updateTable();
updatePagination();
showToast('Record deleted successfully', 'success');
}
}
}
function closeRecordModal() {
const modal = document.getElementById('recordModal');
if (modal) {
modal.classList.remove('show');
setTimeout(() => {
modal.style.display = 'none';
}, 200);
}
}
// Export functionality
function exportAttendance() {
const exportData = filteredData.map(record => ({
'Employee ID': record.employeeId,
'Location': record.location,
'Event': record.event,
'Date': record.date,
'Time': record.time,
'Device': record.device,
'Status': record.status
}));
const csv = convertToCSV(exportData);
downloadCSV(csv, `attendance_report_${new Date().toISOString().split('T')[0]}.csv`);
showToast('Attendance data exported successfully', 'success');
}
function convertToCSV(data) {
if (!data.length) return '';
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map(row =>
headers.map(header => {
const value = row[header];
// Escape commas and quotes
return typeof value === 'string' && (value.includes(',') || value.includes('"'))
? `"${value.replace(/"/g, '""')}"`
: value;
}).join(',')
)
].join('\n');
return csvContent;
}
function downloadCSV(csv, filename) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
if (link.download !== undefined) {
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
function refreshReport() {
showToast('Refreshing report...', 'info');
setTimeout(() => {
window.location.reload();
}, 500);
}
// Charts initialization
function initializeCharts() {
loadAttendanceStats();
}
function loadAttendanceStats() {
fetch('/api/attendance/stats')
.then(response => response.json())
.then(data => {
createDailyChart(data.daily_stats || []);
createLocationChart(data.location_stats || []);
})
.catch(error => {
console.error('Error loading attendance stats:', error);
});
}
function createDailyChart(dailyStats) {
const ctx = document.getElementById('dailyChart');
if (!ctx) return;
if (dailyChart) {
dailyChart.destroy();
}
dailyChart = new Chart(ctx, {
type: 'line',
data: {
labels: dailyStats.map(stat => stat.date),
datasets: [{
label: 'Check-ins',
data: dailyStats.map(stat => stat.checkins),
borderColor: '#2563eb',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
tension: 0.4,
fill: true
}, {
label: 'Unique Employees',
data: dailyStats.map(stat => stat.employees),
borderColor: '#059669',
backgroundColor: 'rgba(5, 150, 105, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
function createLocationChart(locationStats) {
const ctx = document.getElementById('locationChart');
if (!ctx) return;
if (locationChart) {
locationChart.destroy();
}
locationChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: locationStats.map(stat => stat.location),
datasets: [{
data: locationStats.map(stat => stat.checkins),
backgroundColor: [
'#2563eb',
'#059669',
'#d97706',
'#dc2626',
'#7c3aed',
'#0891b2',
'#65a30d',
'#c2410c'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'right'
}
}
}
});
}
// Utility functions
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<div class="toast-content">
<i class="fas ${getToastIcon(type)}"></i>
<span>${message}</span>
</div>
`;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 1rem;
z-index: 1000;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
border-left: 4px solid ${getToastColor(type)};
max-width: 400px;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateX(0)';
}, 100);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
function getToastIcon(type) {
const icons = {
success: 'fa-check-circle',
error: 'fa-exclamation-circle',
warning: 'fa-exclamation-triangle',
info: 'fa-info-circle'
};
return icons[type] || icons.info;
}
function getToastColor(type) {
const colors = {
success: '#059669',
error: '#dc2626',
warning: '#d97706',
info: '#0891b2'
};
return colors[type] || colors.info;
}
// Global function exports
window.sortTable = sortTable;
window.changeEntriesPerPage = changeEntriesPerPage;
window.clearFilters = clearFilters;
window.goToPage = goToPage;
window.viewRecordDetails = viewRecordDetails;
window.editRecord = editRecord;
window.deleteRecord = deleteRecord;
window.closeRecordModal = closeRecordModal;
window.exportAttendance = exportAttendance;
window.refreshReport = refreshReport;
+457
View File
@@ -0,0 +1,457 @@
class DashboardManager {
constructor() {
this.selectedQRCodes = new Set();
this.allExpanded = false;
this.init();
}
init() {
this.setupEventListeners();
this.addScrollAnimations();
}
animateOut(element, callback) {
element.classList.add("fade-out");
setTimeout(() => {
if (callback) callback();
element.style.display = "none";
}, 300);
}
// Setup event listeners
setupEventListeners() {
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// ESC to close modals
if (e.key === "Escape") {
this.closeQRModal();
}
// Ctrl/Cmd + F to focus search
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
const searchInput = document.getElementById("qrSearch");
if (searchInput) searchInput.focus();
}
// Delete key for bulk delete (when items selected)
if (e.key === "Delete" && this.selectedQRCodes.size > 0) {
e.preventDefault();
this.bulkDeleteQRCodes();
}
});
// Expand/collapse all toggle
const expandToggle = document.getElementById("expandAllToggle");
if (expandToggle) {
expandToggle.addEventListener("click", () => {
this.toggleExpandAll();
});
}
}
// Add scroll animations for QR items
addScrollAnimations() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("animate-in");
}
});
},
{ threshold: 0.1 }
);
const qrItems = document.querySelectorAll(".qr-item");
qrItems.forEach((item) => observer.observe(item));
}
// FIXED: QR Code Toggle Status
toggleQRCodeStatus(qrId) {
const qrItem = document.querySelector(`[data-qr-id="${qrId}"]`);
if (!qrItem) return;
const currentStatus = qrItem.dataset.status;
const newStatus = currentStatus === "active" ? "inactive" : "active";
// Show loading state
const toggleBtn = document.getElementById(`toggle-btn-${qrId}`);
const toggleIcon = document.getElementById(`toggle-icon-${qrId}`);
if (toggleBtn && toggleIcon) {
toggleBtn.classList.add("status-loading");
toggleIcon.className = "fas fa-spinner fa-spin";
toggleBtn.disabled = true;
}
// FIXED: Use correct endpoint with POST method for JSON response
fetch(`/qr-codes/${qrId}/toggle-status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((result) => {
if (result.success) {
// Update UI with new status
this.updateQRStatus(qrId, result.new_status ? "active" : "inactive");
window.showToast(result.message, "success");
} else {
throw new Error(result.message || "Failed to update status");
}
})
.catch((error) => {
console.error("Status update error:", error);
window.showToast("Failed to update QR code status", "error");
})
.finally(() => {
// Remove loading state
if (toggleBtn && toggleIcon) {
toggleBtn.classList.remove("status-loading");
toggleBtn.disabled = false;
// Restore icon based on current status
const currentStatus = qrItem.dataset.status;
toggleIcon.className = `fas ${
currentStatus === "active" ? "fa-pause" : "fa-play"
}`;
}
});
}
// Update QR status in UI
updateQRStatus(qrId, newStatus) {
const qrItem = document.querySelector(`[data-qr-id="${qrId}"]`);
if (!qrItem) return;
// Update data attribute
qrItem.dataset.status = newStatus;
// Update status badge
const statusBadge = qrItem.querySelector(".qr-status");
if (statusBadge) {
statusBadge.className = `qr-status ${newStatus}`;
statusBadge.innerHTML = `
<i class="fas ${
newStatus === "active" ? "fa-check-circle" : "fa-times-circle"
}"></i>
${newStatus === "active" ? "Active" : "Inactive"}
`;
}
// Update toggle buttons
this.updateToggleButton(qrId, newStatus);
}
// Update toggle button appearance
updateToggleButton(qrId, status) {
const toggleBtn = document.getElementById(`toggle-btn-${qrId}`);
const toggleIcon = document.getElementById(`toggle-icon-${qrId}`);
const detailToggleBtn = document.getElementById(
`detail-toggle-btn-${qrId}`
);
if (toggleBtn && toggleIcon) {
// Update quick action button
toggleBtn.className = `action-btn btn-status ${
status === "active" ? "btn-deactivate" : "btn-activate"
}`;
toggleBtn.title = `${
status === "active" ? "Deactivate" : "Activate"
} QR Code`;
toggleIcon.className = `fas ${
status === "active" ? "fa-pause" : "fa-play"
}`;
}
if (detailToggleBtn) {
// Update detail action button
detailToggleBtn.className = `btn ${
status === "active" ? "btn-warning" : "btn-success"
}`;
detailToggleBtn.innerHTML = `
<i class="fas ${status === "active" ? "fa-pause" : "fa-play"}"></i>
${status === "active" ? "Deactivate" : "Activate"} QR Code
`;
}
}
async deleteQRCode(qrId, qrName) {
if (!confirm(`Delete "${qrName}"? This cannot be undone.`)) return;
try {
// Show loading state
const deleteBtn = document.querySelector(`[onclick*="deleteQRCode(${qrId}"]`);
if (deleteBtn) {
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
}
const response = await fetch(`/qr-codes/${qrId}/delete`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Don't try to parse as JSON - just check if request was successful
if (response.ok) {
// Remove QR item from page immediately
const qrItem = document.querySelector(`[data-qr-id="${qrId}"]`);
if (qrItem) {
qrItem.style.transition = "opacity 0.3s";
qrItem.style.opacity = "0";
setTimeout(() => {
qrItem.remove();
if (this.updateResultsCount) this.updateResultsCount();
}, 300);
}
// Use simple alert instead of problematic showToast
alert(`QR code "${qrName}" deleted successfully!`);
} else {
throw new Error(`Server error: ${response.status}`);
}
} catch (error) {
console.error("Delete error:", error);
// Restore button if there was an error
const deleteBtn = document.querySelector(`[onclick*="deleteQRCode(${qrId}"]`);
if (deleteBtn) {
deleteBtn.disabled = false;
deleteBtn.innerHTML = '<i class="fas fa-trash"></i>';
}
// Use simple alert instead of problematic showToast
alert("Failed to delete QR code. Please try again.");
}
}
// Show custom delete confirmation dialog
showDeleteConfirmation(qrName) {
return new Promise((resolve) => {
const modal = document.createElement("div");
modal.className = "modal";
modal.style.display = "flex";
modal.innerHTML = `
<div class="modal-content confirmation-modal">
<div class="modal-header">
<h3><i class="fas fa-exclamation-triangle text-warning"></i> Confirm Deletion</h3>
</div>
<div class="modal-body">
<p><strong>Are you sure you want to permanently delete "${qrName}"?</strong></p>
<p class="text-muted">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button class="btn btn-danger" onclick="confirmDelete()">
<i class="fas fa-trash"></i> Delete
</button>
<button class="btn btn-secondary" onclick="cancelDelete()">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
window.confirmDelete = () => {
document.body.removeChild(modal);
delete window.confirmDelete;
delete window.cancelDelete;
resolve(true);
};
window.cancelDelete = () => {
document.body.removeChild(modal);
delete window.confirmDelete;
delete window.cancelDelete;
resolve(false);
};
// Close on ESC key
const escHandler = (e) => {
if (e.key === "Escape") {
window.cancelDelete();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
// Close on backdrop click
modal.addEventListener("click", (e) => {
if (e.target === modal) {
window.cancelDelete();
}
});
});
}
// Bulk delete functionality
async bulkDeleteQRCodes() {
if (this.selectedQRCodes.size === 0) return;
const confirmed = await this.showBulkDeleteConfirmation(this.selectedQRCodes.size);
if (!confirmed) return;
const deletePromises = Array.from(this.selectedQRCodes).map(qrId => {
const qrItem = document.querySelector(`[data-qr-id="${qrId}"]`);
const qrName = qrItem ? qrItem.querySelector('.qr-name')?.textContent || 'Unknown' : 'Unknown';
return this.deleteQRCode(qrId, qrName);
});
try {
await Promise.all(deletePromises);
this.selectedQRCodes.clear();
window.showToast(`Successfully deleted ${deletePromises.length} QR codes`, "success");
} catch (error) {
console.error("Bulk delete error:", error);
window.showToast("Some QR codes could not be deleted", "error");
}
}
showBulkDeleteConfirmation(count) {
return new Promise((resolve) => {
const modal = document.createElement("div");
modal.className = "modal";
modal.style.display = "flex";
modal.innerHTML = `
<div class="modal-content confirmation-modal">
<div class="modal-header">
<h3><i class="fas fa-exclamation-triangle text-warning"></i> Confirm Bulk Deletion</h3>
</div>
<div class="modal-body">
<p><strong>Are you sure you want to permanently delete ${count} QR codes?</strong></p>
<p class="text-muted">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button class="btn btn-danger" onclick="confirmBulkDelete()">
<i class="fas fa-trash"></i> Delete All
</button>
<button class="btn btn-secondary" onclick="cancelBulkDelete()">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
window.confirmBulkDelete = () => {
document.body.removeChild(modal);
delete window.confirmBulkDelete;
delete window.cancelBulkDelete;
resolve(true);
};
window.cancelBulkDelete = () => {
document.body.removeChild(modal);
delete window.confirmBulkDelete;
delete window.cancelBulkDelete;
resolve(false);
};
});
}
// QR Modal functions
openQRModal(qrData) {
const modal = document.getElementById("qrModal");
const modalImage = document.getElementById("modalQRImage");
const modalTitle = document.getElementById("modalTitle");
if (modal && modalImage && modalTitle) {
modalTitle.textContent = `QR Code: ${qrData.name}`;
modalImage.src = qrData.image;
modal.style.display = "flex";
}
}
closeQRModal() {
const modal = document.getElementById("qrModal");
if (modal) {
modal.style.display = "none";
}
}
// Expand/collapse functionality
toggleExpandAll() {
const qrItems = document.querySelectorAll(".qr-item");
const expandToggle = document.getElementById("expandAllToggle");
this.allExpanded = !this.allExpanded;
qrItems.forEach((item) => {
if (this.allExpanded) {
item.classList.add("expanded");
} else {
item.classList.remove("expanded");
}
});
if (expandToggle) {
expandToggle.innerHTML = this.allExpanded
? '<i class="fas fa-compress-alt"></i> Collapse All'
: '<i class="fas fa-expand-alt"></i> Expand All';
}
}
toggleQRItem(element) {
element.classList.toggle("expanded");
}
// Copy QR data to clipboard
copyQRData(name, location, address, event) {
const data = `QR Code: ${name}\nLocation: ${location}\nAddress: ${address}\nEvent: ${event}`;
navigator.clipboard
.writeText(data)
.then(() => {
window.showToast("QR code information copied to clipboard!", "success");
})
.catch(() => {
window.showToast("Failed to copy to clipboard", "error");
});
}
// Update results display
updateResultsDisplay(count) {
const resultsDisplay = document.getElementById("resultsDisplay");
if (resultsDisplay) {
resultsDisplay.textContent = `${count} QR codes found`;
}
}
updateResultsCount() {
const qrItems = document.querySelectorAll(
'.qr-item[style*="block"], .qr-item:not([style*="none"])'
);
const counter = document.querySelector(".results-counter");
if (counter) {
counter.textContent = `${qrItems.length} results`;
}
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener("DOMContentLoaded", function () {
window.dashboardManager = new DashboardManager();
// Global functions for inline event handlers
window.toggleQRCodeStatus = (qrId) =>
window.dashboardManager.toggleQRCodeStatus(qrId);
window.deleteQRCode = (qrId, qrName) =>
window.dashboardManager.deleteQRCode(qrId, qrName);
window.openQRModal = (qrData) => window.dashboardManager.openQRModal(qrData);
window.closeQRModal = () => window.dashboardManager.closeQRModal();
window.toggleQRItem = (element) =>
window.dashboardManager.toggleQRItem(element);
window.copyQRData = (name, location, address, event) =>
window.dashboardManager.copyQRData(name, location, address, event);
});
+573
View File
@@ -0,0 +1,573 @@
/**
* Dashboard-specific JavaScript functionality with QR Toggle
* static/js/dashboard.js
*/
// Dashboard QR Management Class
class DashboardManager {
constructor() {
this.allExpanded = false;
this.currentModalQR = null;
this.init();
}
init() {
this.initializeSearch();
this.initializeFilters();
this.setupEventListeners();
this.addScrollAnimations();
this.updateResultsCount();
}
// Initialize search functionality with debouncing
initializeSearch() {
const searchInput = document.getElementById("qrSearch");
if (!searchInput) return;
let searchTimeout;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.filterQRCodes();
}, 300);
});
}
// Initialize filter functionality
initializeFilters() {
const statusFilter = document.getElementById("statusFilter");
if (!statusFilter) return;
statusFilter.addEventListener("change", () => {
this.filterQRCodes();
});
}
// Enhanced QR code filtering with animation
filterQRCodes() {
const searchTerm = document.getElementById("qrSearch").value.toLowerCase();
const statusFilter = document.getElementById("statusFilter").value;
const qrItems = document.querySelectorAll(".qr-item");
let visibleCount = 0;
qrItems.forEach((item) => {
const name = item.dataset.name || "";
const location = item.dataset.location || "";
const status = item.dataset.status || "";
const matchesSearch =
!searchTerm ||
name.includes(searchTerm) ||
location.includes(searchTerm);
const matchesStatus = !statusFilter || status === statusFilter;
if (matchesSearch && matchesStatus) {
this.showQRItem(item);
visibleCount++;
} else {
this.hideQRItem(item);
}
});
this.updateResultsDisplay(visibleCount);
this.updateResultsCount();
}
// Show QR item with animation
showQRItem(item) {
item.style.display = "block";
setTimeout(() => {
item.classList.add("fade-in");
item.classList.remove("fade-out");
}, 10);
}
// Hide QR item with animation
hideQRItem(item) {
item.classList.add("fade-out");
item.classList.remove("fade-in");
setTimeout(() => {
item.style.display = "none";
}, 300);
}
// Update results display and empty state
updateResultsDisplay(count) {
const qrList = document.getElementById("qrList");
let existingEmpty = document.querySelector(".search-empty-state");
if (
count === 0 &&
(document.getElementById("qrSearch").value ||
document.getElementById("statusFilter").value)
) {
if (!existingEmpty) {
const emptyState = document.createElement("div");
emptyState.className = "search-empty-state";
emptyState.innerHTML = `
<div class="empty-icon">
<i class="fas fa-search"></i>
</div>
<h3>No QR Codes Found</h3>
<p>Try adjusting your search or filter criteria</p>
<button onclick="dashboardManager.clearFilters()" class="btn btn-outline">
<i class="fas fa-refresh"></i>
Clear Filters
</button>
`;
qrList.parentNode.appendChild(emptyState);
}
} else {
if (existingEmpty) {
existingEmpty.remove();
}
}
}
// Clear all filters
clearFilters() {
document.getElementById("qrSearch").value = "";
document.getElementById("statusFilter").value = "";
this.filterQRCodes();
}
// Update results counter
updateResultsCount() {
const qrItems = document.querySelectorAll(
'.qr-item[style*="block"], .qr-item:not([style*="none"])'
);
const totalItems = document.querySelectorAll(".qr-item").length;
const visibleCount = qrItems.length;
let counter = document.querySelector(".results-counter");
if (!counter) {
counter = document.createElement("div");
counter.className = "results-counter";
const searchContainer = document.querySelector(".search-container");
if (searchContainer) {
searchContainer.appendChild(counter);
}
}
if (visibleCount !== totalItems) {
counter.textContent = `Showing ${visibleCount} of ${totalItems} QR codes`;
counter.style.display = "block";
} else {
counter.style.display = "none";
}
}
// Setup additional event listeners
setupEventListeners() {
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case "f":
e.preventDefault();
document.getElementById("qrSearch")?.focus();
break;
}
}
});
// Enhanced modal functionality
this.setupModalHandling();
}
// Enhanced modal handling
setupModalHandling() {
const modal = document.getElementById("qrModal");
if (!modal) return;
// Close modal with better animation
const closeButtons = modal.querySelectorAll("[onclick*='closeQRModal']");
closeButtons.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
this.closeQRModal();
});
});
// Click outside to close
modal.addEventListener("click", (e) => {
if (e.target === modal) {
this.closeQRModal();
}
});
// Escape key to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display === "flex") {
this.closeQRModal();
}
});
}
// Close QR modal with improved animation
closeQRModal() {
const modal = document.getElementById("qrModal");
if (modal) {
modal.classList.remove("show");
setTimeout(() => {
modal.style.display = "none";
}, 200);
}
this.currentModalQR = null;
}
// Add scroll animations for better UX
addScrollAnimations() {
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -50px 0px",
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.style.opacity = "1";
entry.target.style.transform = "translateY(0)";
}
});
}, observerOptions);
// Observe QR items
document.querySelectorAll(".qr-item").forEach((item) => {
item.style.opacity = "0";
item.style.transform = "translateY(20px)";
item.style.transition = "opacity 0.6s ease, transform 0.6s ease";
observer.observe(item);
});
}
// Toggle QR details with smooth animation
toggleQRDetails(qrId) {
const details = document.getElementById(`qr-details-${qrId}`);
const chevron = document.querySelector(
`[onclick="toggleQRDetails(${qrId})"] .chevron`
);
if (!details) return;
if (details.classList.contains("expanded")) {
details.classList.remove("expanded");
if (chevron) chevron.style.transform = "rotate(0deg)";
} else {
// Close other expanded items first
document
.querySelectorAll(".qr-item-details.expanded")
.forEach((detail) => {
if (detail !== details) {
detail.classList.remove("expanded");
}
});
details.classList.add("expanded");
if (chevron) chevron.style.transform = "rotate(180deg)";
}
}
// Toggle all QR codes expand/collapse
toggleAllQRs() {
const details = document.querySelectorAll(".qr-item-details");
const expandIcon = document.getElementById("expandIcon");
const expandText = document.getElementById("expandText");
this.allExpanded = !this.allExpanded;
details.forEach((detail) => {
if (this.allExpanded) {
detail.classList.add("expanded");
} else {
detail.classList.remove("expanded");
}
});
// Update button text and icon
if (expandIcon && expandText) {
if (this.allExpanded) {
expandIcon.className = "fas fa-compress-alt";
expandText.textContent = "Collapse All";
} else {
expandIcon.className = "fas fa-expand-alt";
expandText.textContent = "Expand All";
}
}
// Update chevron icons
document.querySelectorAll(".chevron").forEach((chevron) => {
chevron.style.transform = this.allExpanded
? "rotate(180deg)"
: "rotate(0deg)";
});
}
// Enhanced QR preview functionality
previewQR(qrData, qrName) {
const modal = document.getElementById("qrModal");
const modalTitle = document.getElementById("modalTitle");
const modalImage = document.getElementById("modalQRImage");
if (modal && modalTitle && modalImage) {
modalTitle.textContent = `${qrName} - QR Code`;
modalImage.src = `data:image/png;base64,${qrData}`;
modalImage.alt = `QR Code for ${qrName}`;
this.currentModalQR = {
name: qrName,
image: `data:image/png;base64,${qrData}`,
};
modal.style.display = "flex";
setTimeout(() => modal.classList.add("show"), 10);
}
}
// Enhanced download functionality
downloadQR(base64Image, filename) {
try {
const link = document.createElement("a");
link.href = `data:image/png;base64,${base64Image}`;
link.download = `${filename
.replace(/[^a-z0-9]/gi, "_")
.toLowerCase()}_qr_code.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Show success toast
this.showToast("QR code downloaded successfully!", "success");
} catch (error) {
this.showToast("Failed to download QR code", "error");
console.error("Download error:", error);
}
}
// Download from modal
downloadModalQR() {
if (this.currentModalQR) {
const base64Data = this.currentModalQR.image.split("base64,")[1];
this.downloadQR(base64Data, this.currentModalQR.name);
}
}
// NEW: Toggle QR Code Status (Activate/Deactivate)
async toggleQRCodeStatus(qrId) {
const qrItem = document.querySelector(`[data-qr-id="${qrId}"]`);
const statusElement = document.getElementById(`status-${qrId}`);
const statusIcon = document.getElementById(`status-icon-${qrId}`);
const statusText = document.getElementById(`status-text-${qrId}`);
const toggleBtn = document.getElementById(`toggle-btn-${qrId}`);
const toggleIcon = document.getElementById(`toggle-icon-${qrId}`);
const detailToggleBtn = document.getElementById(
`detail-toggle-btn-${qrId}`
);
if (!statusElement || !qrItem) return;
// Add loading state
statusElement.classList.add("status-loading");
if (toggleBtn) toggleBtn.disabled = true;
if (detailToggleBtn) detailToggleBtn.disabled = true;
try {
const response = await fetch(`/qr-codes/${qrId}/toggle-status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (data.success) {
// Update UI elements
const newStatus = data.new_status;
const newStatusClass = newStatus ? "active" : "inactive";
const newIconClass = newStatus ? "fa-check-circle" : "fa-times-circle";
const newToggleIcon = newStatus ? "fa-pause" : "fa-play";
const newToggleBtnClass = newStatus ? "btn-deactivate" : "btn-activate";
const newDetailBtnClass = newStatus ? "btn-warning" : "btn-success";
const newDetailBtnText = newStatus ? "Deactivate" : "Activate";
// Update status badge
statusElement.className = `qr-status ${newStatusClass}`;
if (statusIcon) statusIcon.className = `fas ${newIconClass}`;
if (statusText) statusText.textContent = data.status_text;
// Update QR item data attribute and styling
qrItem.setAttribute("data-status", newStatusClass);
// Update toggle button (collapsed view)
if (toggleBtn) {
toggleBtn.className = `action-btn btn-status ${newToggleBtnClass}`;
toggleBtn.title = `${newDetailBtnText} QR Code`;
}
if (toggleIcon) {
toggleIcon.className = `fas ${newToggleIcon}`;
}
// Update detail toggle button (expanded view)
if (detailToggleBtn) {
detailToggleBtn.className = `btn ${newDetailBtnClass}`;
detailToggleBtn.innerHTML = `<i class="fas ${newToggleIcon}"></i> ${newDetailBtnText} QR Code`;
}
// Update status badges in expanded view
const detailStatusBadges = qrItem.querySelectorAll(".status-badge");
detailStatusBadges.forEach((badge) => {
badge.className = `status-badge ${newStatusClass}`;
const icon = badge.querySelector("i");
if (icon) icon.className = `fas ${newIconClass}`;
const text = badge.textContent.trim();
if (text === "Active" || text === "Inactive") {
badge.innerHTML = `<i class="fas ${newIconClass}"></i> ${data.status_text}`;
}
});
// Show success message
this.showToast(data.message, "success");
// Update statistics if needed
this.updateStatistics();
} else {
this.showToast(
data.message || "Failed to update QR code status",
"error"
);
}
} catch (error) {
console.error("Error toggling QR status:", error);
this.showToast("Network error. Please try again.", "error");
} finally {
// Remove loading state
statusElement.classList.remove("status-loading");
if (toggleBtn) toggleBtn.disabled = false;
if (detailToggleBtn) detailToggleBtn.disabled = false;
}
}
// Update statistics after status change
updateStatistics() {
const activeCount = document.querySelectorAll(
'[data-status="active"]'
).length;
const inactiveCount = document.querySelectorAll(
'[data-status="inactive"]'
).length;
const totalCount = activeCount + inactiveCount;
// Update active count
const activeStatElement = document.querySelector(".stat-card.success h3");
if (activeStatElement) {
activeStatElement.textContent = activeCount;
}
// Update active percentage
const activePercentElement = document.querySelector(
".stat-card.success .stat-trend"
);
if (activePercentElement && totalCount > 0) {
const percentage = ((activeCount / totalCount) * 100).toFixed(1);
activePercentElement.textContent = `${percentage}% active`;
}
// Update inactive count if there's a specific stat card for it
const inactiveStatElement = document.querySelector(".stat-card.warning h3");
if (inactiveStatElement) {
inactiveStatElement.textContent = inactiveCount;
}
// Update inactive percentage
const inactivePercentElement = document.querySelector(
".stat-card.warning .stat-trend"
);
if (inactivePercentElement && totalCount > 0) {
const percentage = ((inactiveCount / totalCount) * 100).toFixed(1);
inactivePercentElement.textContent = `${percentage}% inactive`;
}
}
// Toast notification system
showToast(message, type = "info") {
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<div class="toast-content">
<i class="fas ${this.getToastIcon(type)}"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
// Animate in
setTimeout(() => toast.classList.add("show"), 100);
// Auto remove
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
// Get toast icon based on type
getToastIcon(type) {
const icons = {
success: "fa-check-circle",
error: "fa-exclamation-circle",
warning: "fa-exclamation-triangle",
info: "fa-info-circle",
};
return icons[type] || icons.info;
}
}
// Global functions for compatibility with existing onclick handlers
let dashboardManager;
function toggleQRDetails(qrId) {
dashboardManager?.toggleQRDetails(qrId);
}
function toggleAllQRs() {
dashboardManager?.toggleAllQRs();
}
function previewQR(qrData, qrName) {
dashboardManager?.previewQR(qrData, qrName);
}
function downloadQR(base64Image, filename) {
dashboardManager?.downloadQR(base64Image, filename);
}
function closeQRModal() {
dashboardManager?.closeQRModal();
}
function downloadModalQR() {
dashboardManager?.downloadModalQR();
}
// NEW: Global function for QR status toggle
function toggleQRCodeStatus(qrId) {
dashboardManager?.toggleQRCodeStatus(qrId);
}
// Initialize dashboard when DOM is ready
document.addEventListener("DOMContentLoaded", function () {
dashboardManager = new DashboardManager();
// Add helpful keyboard shortcuts tooltip
console.log("Dashboard keyboard shortcuts:");
console.log("Ctrl/Cmd + F: Focus search");
console.log("Escape: Close modal");
});
+590
View File
@@ -0,0 +1,590 @@
/**
* QR Code Destination Page JavaScript
* Handles staff check-in functionality and form interactions
*/
// Global variables
let isSubmitting = false;
let currentTime = new Date();
// Initialize page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('QR Destination page initialized');
initializePage();
setupEventListeners();
startTimeUpdater();
});
function initializePage() {
// Focus on employee ID input
const employeeInput = document.getElementById('employee_id');
if (employeeInput) {
employeeInput.focus();
}
// Initialize current time display
updateCurrentTime();
// Add page load animation
document.body.classList.add('page-loaded');
}
function setupEventListeners() {
const form = document.getElementById('checkinForm');
const employeeInput = document.getElementById('employee_id');
if (form) {
form.addEventListener('submit', handleFormSubmit);
}
if (employeeInput) {
// Real-time input validation
employeeInput.addEventListener('input', handleInputChange);
employeeInput.addEventListener('blur', validateEmployeeId);
employeeInput.addEventListener('keypress', handleKeyPress);
// Auto-uppercase input
employeeInput.addEventListener('input', function() {
this.value = this.value.toUpperCase();
});
}
// Handle page visibility changes
document.addEventListener('visibilitychange', handleVisibilityChange);
}
function handleFormSubmit(e) {
e.preventDefault();
if (isSubmitting) {
return false;
}
const employeeId = document.getElementById('employee_id').value.trim();
if (!validateEmployeeId()) {
return false;
}
submitCheckin(employeeId);
}
function handleInputChange(e) {
const input = e.target;
const value = input.value.trim();
// Clear previous validation states
input.classList.remove('error', 'success');
hideStatusMessage();
// Real-time validation feedback
if (value.length >= 3) {
if (isValidEmployeeId(value)) {
input.classList.add('success');
} else {
input.classList.add('error');
}
}
}
function handleKeyPress(e) {
// Allow only alphanumeric characters
const char = String.fromCharCode(e.which);
if (!/[A-Za-z0-9]/.test(char)) {
e.preventDefault();
shakeInput(e.target);
}
// Submit on Enter key
if (e.key === 'Enter') {
e.preventDefault();
handleFormSubmit(e);
}
}
function validateEmployeeId() {
const employeeInput = document.getElementById('employee_id');
const employeeId = employeeInput.value.trim();
if (!employeeId) {
showValidationError(employeeInput, 'Employee ID is required');
return false;
}
if (employeeId.length < 3) {
showValidationError(employeeInput, 'Employee ID must be at least 3 characters');
return false;
}
if (employeeId.length > 20) {
showValidationError(employeeInput, 'Employee ID must be less than 20 characters');
return false;
}
if (!isValidEmployeeId(employeeId)) {
showValidationError(employeeInput, 'Employee ID can only contain letters and numbers');
return false;
}
// Clear validation error
employeeInput.classList.remove('error');
employeeInput.classList.add('success');
hideStatusMessage();
return true;
}
function isValidEmployeeId(id) {
return /^[A-Za-z0-9]{3,20}$/.test(id);
}
function showValidationError(input, message) {
input.classList.add('error');
input.classList.remove('success');
showStatusMessage(message, 'error');
shakeInput(input);
input.focus();
}
function shakeInput(input) {
input.classList.add('shake');
setTimeout(() => {
input.classList.remove('shake');
}, 500);
}
function submitCheckin(employeeId) {
if (isSubmitting) return;
isSubmitting = true;
showLoadingState();
showLoadingOverlay();
// Prepare form data
const formData = new FormData();
formData.append('employee_id', employeeId);
// Submit to server
fetch(`/qr/${window.qrUrl}/checkin`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
handleCheckinResponse(data);
})
.catch(error => {
console.error('Check-in error:', error);
handleCheckinError('Network error. Please check your connection and try again.');
})
.finally(() => {
isSubmitting = false;
hideLoadingState();
hideLoadingOverlay();
});
}
function handleCheckinResponse(data) {
if (data.success) {
showSuccessCard(data.data);
logSuccessfulCheckin(data.data);
// Optional: Analytics tracking
if (typeof gtag !== 'undefined') {
gtag('event', 'checkin_success', {
'location': window.locationName,
'event_name': window.eventName
});
}
} else {
handleCheckinError(data.message);
}
}
function handleCheckinError(message) {
showStatusMessage(message, 'error');
// Shake the form to draw attention
const form = document.getElementById('checkinForm');
if (form) {
form.classList.add('shake');
setTimeout(() => {
form.classList.remove('shake');
}, 500);
}
// Re-focus on input
const employeeInput = document.getElementById('employee_id');
if (employeeInput) {
employeeInput.focus();
employeeInput.select();
}
}
function showSuccessCard(data) {
// Hide the check-in form
const checkinCard = document.querySelector('.checkin-card');
if (checkinCard) {
checkinCard.style.display = 'none';
}
// Populate and show success card
const successCard = document.getElementById('successCard');
if (successCard) {
document.getElementById('successEmployeeId').textContent = data.employee_id || '-';
document.getElementById('successLocation').textContent = data.location || '-';
document.getElementById('successEvent').textContent = data.event || '-';
document.getElementById('successTime').textContent = data.time || '-';
document.getElementById('successDate').textContent = data.date || '-';
successCard.style.display = 'block';
successCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Optional: Auto-hide success card after some time
setTimeout(() => {
showAutoHideOption();
}, 10000); // 10 seconds
}
function showAutoHideOption() {
const successCard = document.getElementById('successCard');
if (successCard && successCard.style.display !== 'none') {
const actions = successCard.querySelector('.success-actions');
if (actions && !actions.querySelector('.auto-hide-btn')) {
const autoHideBtn = document.createElement('button');
autoHideBtn.className = 'btn btn-outline auto-hide-btn';
autoHideBtn.innerHTML = '<i class="fas fa-clock"></i> Auto-hide in <span id="countdown">30</span>s';
actions.appendChild(autoHideBtn);
startCountdown(30, () => {
checkInAnother();
});
}
}
}
function startCountdown(seconds, callback) {
const countdownElement = document.getElementById('countdown');
let remaining = seconds;
const interval = setInterval(() => {
remaining--;
if (countdownElement) {
countdownElement.textContent = remaining;
}
if (remaining <= 0) {
clearInterval(interval);
callback();
}
}, 1000);
}
function checkInAnother() {
// Show the check-in form again
const checkinCard = document.querySelector('.checkin-card');
const successCard = document.getElementById('successCard');
if (checkinCard) {
checkinCard.style.display = 'block';
}
if (successCard) {
successCard.style.display = 'none';
}
// Reset form
const form = document.getElementById('checkinForm');
if (form) {
form.reset();
}
// Clear validation states
const employeeInput = document.getElementById('employee_id');
if (employeeInput) {
employeeInput.classList.remove('error', 'success');
employeeInput.focus();
}
hideStatusMessage();
// Scroll back to form
checkinCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function showLoadingState() {
const btn = document.querySelector('.btn-primary');
if (btn) {
const content = btn.querySelector('.btn-content');
const loader = btn.querySelector('.btn-loader');
if (content) content.style.display = 'none';
if (loader) loader.style.display = 'flex';
btn.disabled = true;
}
}
function hideLoadingState() {
const btn = document.querySelector('.btn-primary');
if (btn) {
const content = btn.querySelector('.btn-content');
const loader = btn.querySelector('.btn-loader');
if (content) content.style.display = 'flex';
if (loader) loader.style.display = 'none';
btn.disabled = false;
}
}
function showLoadingOverlay() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.style.display = 'flex';
setTimeout(() => {
overlay.classList.add('show');
}, 10);
}
}
function hideLoadingOverlay() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.classList.remove('show');
setTimeout(() => {
overlay.style.display = 'none';
}, 200);
}
}
function showStatusMessage(message, type = 'info') {
const statusDiv = document.getElementById('statusMessage');
if (statusDiv) {
statusDiv.textContent = message;
statusDiv.className = `status-message ${type}`;
statusDiv.style.display = 'block';
// Auto-hide success messages
if (type === 'success') {
setTimeout(() => {
hideStatusMessage();
}, 5000);
}
// Scroll to message
statusDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function hideStatusMessage() {
const statusDiv = document.getElementById('statusMessage');
if (statusDiv) {
statusDiv.style.display = 'none';
}
}
function updateCurrentTime() {
const timeElement = document.getElementById('currentTime');
if (timeElement) {
const now = new Date();
const options = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
};
timeElement.textContent = now.toLocaleDateString('en-US', options);
}
}
function startTimeUpdater() {
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
}
function handleVisibilityChange() {
if (document.hidden) {
// Page is hidden - pause operations
console.log('Page hidden - pausing operations');
} else {
// Page is visible - resume operations
console.log('Page visible - resuming operations');
updateCurrentTime();
// Re-focus on input if form is visible
const checkinCard = document.querySelector('.checkin-card');
const employeeInput = document.getElementById('employee_id');
if (checkinCard && checkinCard.style.display !== 'none' && employeeInput) {
setTimeout(() => {
employeeInput.focus();
}, 100);
}
}
}
function logSuccessfulCheckin(data) {
console.log('Successful check-in:', {
employee_id: data.employee_id,
location: data.location,
event: data.event,
time: data.time,
date: data.date
});
}
// Utility functions
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
// Export functions for global access
window.checkInAnother = checkInAnother;
window.validateEmployeeId = validateEmployeeId;
// Service Worker registration for offline support (optional)
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('ServiceWorker registration successful');
})
.catch(function(err) {
console.log('ServiceWorker registration failed: ', err);
});
});
}
// Error handling for unhandled promises
window.addEventListener('unhandledrejection', function(event) {
console.error('Unhandled promise rejection:', event.reason);
handleCheckinError('An unexpected error occurred. Please try again.');
event.preventDefault();
});
// Handle online/offline status
window.addEventListener('online', function() {
showStatusMessage('Connection restored', 'success');
});
window.addEventListener('offline', function() {
showStatusMessage('No internet connection. Please check your network.', 'warning');
});
// Performance monitoring
if ('performance' in window) {
window.addEventListener('load', function() {
setTimeout(function() {
const perfData = performance.getEntriesByType('navigation')[0];
console.log('Page load time:', perfData.loadEventEnd - perfData.loadEventStart, 'ms');
}, 0);
});
}
// Accessibility enhancements
document.addEventListener('keydown', function(e) {
// Escape key to reset form
if (e.key === 'Escape') {
const successCard = document.getElementById('successCard');
if (successCard && successCard.style.display !== 'none') {
checkInAnother();
} else {
// Reset form
const form = document.getElementById('checkinForm');
if (form) {
form.reset();
hideStatusMessage();
const employeeInput = document.getElementById('employee_id');
if (employeeInput) {
employeeInput.classList.remove('error', 'success');
employeeInput.focus();
}
}
}
}
// Ctrl+R to refresh (prevent default and reload page cleanly)
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
window.location.reload();
}
});
// Touch device optimizations
if ('ontouchstart' in window) {
// Add touch-friendly classes
document.body.classList.add('touch-device');
// Prevent zoom on input focus for iOS
const inputs = document.querySelectorAll('input[type="text"]');
inputs.forEach(input => {
input.addEventListener('focus', function() {
const viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}
});
input.addEventListener('blur', function() {
const viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
}
});
});
}
// Auto-refresh page if idle for too long (optional)
let idleTimer;
const IDLE_TIME = 30 * 60 * 1000; // 30 minutes
function resetIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (confirm('This page has been idle for 30 minutes. Would you like to refresh it?')) {
window.location.reload();
} else {
resetIdleTimer(); // Reset timer if user chooses not to refresh
}
}, IDLE_TIME);
}
// Track user activity
['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click'].forEach(event => {
document.addEventListener(event, resetIdleTimer, true);
});
// Initialize idle timer
resetIdleTimer();
+487
View File
@@ -0,0 +1,487 @@
/**
* Main JavaScript functionality for QR Code Management System
* static/js/script.js
*/
// Global configuration
const QRManager = {
config: {
modalCloseDelay: 300,
searchDelay: 300,
animationDuration: 300,
toastDuration: 5000,
},
// Utility functions
utils: {
// Show toast notification
showToast(message, type = "info") {
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<div class="toast-content">
<i class="fas ${this.getToastIcon(type)}"></i>
<span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()" class="toast-close">
<i class="fas fa-times"></i>
</button>
</div>
`;
document.body.appendChild(toast);
// Auto remove after delay
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, QRManager.config.toastDuration);
},
getToastIcon(type) {
const icons = {
success: "fa-check-circle",
error: "fa-exclamation-circle",
warning: "fa-exclamation-triangle",
info: "fa-info-circle",
};
return icons[type] || icons.info;
},
// Debounce function
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Format date
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
},
// Format time
formatTime(dateString) {
const date = new Date(dateString);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
},
},
};
// Navigation functionality
class NavigationManager {
constructor() {
this.initMobileMenu();
this.initDropdowns();
}
initMobileMenu() {
const mobileMenuBtn = document.getElementById("mobile-menu");
const navMenu = document.getElementById("navMenu");
if (mobileMenuBtn && navMenu) {
mobileMenuBtn.addEventListener("click", () => {
mobileMenuBtn.classList.toggle("active");
navMenu.classList.toggle("active");
});
// Close menu when clicking nav links
const navLinks = navMenu.querySelectorAll(".nav-link");
navLinks.forEach((link) => {
link.addEventListener("click", () => {
mobileMenuBtn.classList.remove("active");
navMenu.classList.remove("active");
});
});
}
}
initDropdowns() {
const dropdowns = document.querySelectorAll(".dropdown");
dropdowns.forEach((dropdown) => {
const trigger = dropdown.querySelector(".dropdown-trigger");
const menu = dropdown.querySelector(".dropdown-menu");
if (trigger && menu) {
trigger.addEventListener("click", (e) => {
e.stopPropagation();
this.toggleDropdown(dropdown);
});
}
});
// Close dropdowns when clicking outside
document.addEventListener("click", () => {
this.closeAllDropdowns();
});
}
toggleDropdown(dropdown) {
const menu = dropdown.querySelector(".dropdown-menu");
const isOpen = menu.classList.contains("show");
this.closeAllDropdowns();
if (!isOpen) {
menu.classList.add("show");
}
}
closeAllDropdowns() {
const openMenus = document.querySelectorAll(".dropdown-menu.show");
openMenus.forEach((menu) => {
menu.classList.remove("show");
});
}
}
// Modal management
class ModalManager {
constructor() {
this.initModals();
}
initModals() {
const modals = document.querySelectorAll(".modal");
modals.forEach((modal) => {
// Close button functionality
const closeBtn = modal.querySelector(".modal-close");
if (closeBtn) {
closeBtn.addEventListener("click", () => this.closeModal(modal));
}
// Click outside to close
modal.addEventListener("click", (e) => {
if (e.target === modal) {
this.closeModal(modal);
}
});
});
// Escape key to close all modals
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
this.closeAllModals();
}
});
}
closeModal(modal) {
modal.classList.remove("show");
setTimeout(() => {
modal.style.display = "none";
}, QRManager.config.modalCloseDelay);
}
closeAllModals() {
const openModals = document.querySelectorAll('.modal[style*="flex"]');
openModals.forEach((modal) => this.closeModal(modal));
}
openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = "flex";
setTimeout(() => {
modal.classList.add("show");
}, 10);
}
}
}
// Search functionality
class SearchManager {
constructor(searchInputId, resultsContainerId) {
this.searchInput = document.getElementById(searchInputId);
this.resultsContainer = document.getElementById(resultsContainerId);
this.originalItems = [];
if (this.searchInput && this.resultsContainer) {
this.init();
}
}
init() {
// Store original items
this.originalItems = Array.from(this.resultsContainer.children);
// Add search event listener with debouncing
this.searchInput.addEventListener(
"input",
QRManager.utils.debounce(
() => this.performSearch(),
QRManager.config.searchDelay
)
);
}
performSearch() {
const searchTerm = this.searchInput.value.toLowerCase().trim();
this.originalItems.forEach((item) => {
const searchableText = this.getSearchableText(item);
const matches = searchableText.includes(searchTerm);
if (matches || searchTerm === "") {
this.showItem(item);
} else {
this.hideItem(item);
}
});
this.updateResultsCount();
}
getSearchableText(item) {
// Get text content from data attributes or text content
const name = item.dataset.name || "";
const location = item.dataset.location || "";
const textContent = item.textContent || "";
return (name + " " + location + " " + textContent).toLowerCase();
}
showItem(item) {
item.style.display = "block";
item.classList.remove("fade-out");
item.classList.add("fade-in");
}
hideItem(item) {
item.classList.remove("fade-in");
item.classList.add("fade-out");
setTimeout(() => {
if (item.classList.contains("fade-out")) {
item.style.display = "none";
}
}, QRManager.config.animationDuration);
}
updateResultsCount() {
const visibleItems = this.originalItems.filter(
(item) => item.style.display !== "none"
);
const counter = document.querySelector(".results-counter");
if (counter) {
counter.textContent = `${visibleItems.length} results`;
}
}
}
// Download functionality
class DownloadManager {
static downloadQR(base64Image, filename) {
try {
const link = document.createElement("a");
link.href = "data:image/png;base64," + base64Image;
link.download =
filename.replace(/[^a-z0-9]/gi, "_").toLowerCase() + "_qr_code.png";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
QRManager.utils.showToast("QR code downloaded successfully!", "success");
} catch (error) {
console.error("Download error:", error);
QRManager.utils.showToast("Failed to download QR code", "error");
}
}
static downloadModalQR() {
if (window.currentModalQR) {
const base64Data = window.currentModalQR.image.split("base64,")[1];
this.downloadQR(base64Data, window.currentModalQR.name);
}
}
}
// Theme management
class ThemeManager {
constructor() {
this.initThemeToggle();
this.loadSavedTheme();
}
initThemeToggle() {
const themeToggle = document.getElementById("themeToggle");
if (themeToggle) {
themeToggle.addEventListener("click", () => {
this.toggleTheme();
});
}
}
toggleTheme() {
const currentTheme = document.documentElement.getAttribute("data-theme");
const newTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
this.updateThemeIcon(newTheme);
}
loadSavedTheme() {
const savedTheme = localStorage.getItem("theme") || "light";
document.documentElement.setAttribute("data-theme", savedTheme);
this.updateThemeIcon(savedTheme);
}
updateThemeIcon(theme) {
const themeIcon = document.querySelector("#themeToggle i");
if (themeIcon) {
themeIcon.className = theme === "dark" ? "fas fa-sun" : "fas fa-moon";
}
}
}
// Form validation
class FormValidator {
constructor(formId) {
this.form = document.getElementById(formId);
if (this.form) {
this.init();
}
}
init() {
this.form.addEventListener("submit", (e) => {
if (!this.validateForm()) {
e.preventDefault();
}
});
// Real-time validation
const inputs = this.form.querySelectorAll("input, select, textarea");
inputs.forEach((input) => {
input.addEventListener("blur", () => this.validateField(input));
input.addEventListener("input", () => this.clearFieldError(input));
});
}
validateForm() {
const inputs = this.form.querySelectorAll(
"input[required], select[required], textarea[required]"
);
let isValid = true;
inputs.forEach((input) => {
if (!this.validateField(input)) {
isValid = false;
}
});
return isValid;
}
validateField(field) {
const value = field.value.trim();
const isRequired = field.hasAttribute("required");
const fieldType = field.type;
// Clear previous errors
this.clearFieldError(field);
// Required field validation
if (isRequired && !value) {
this.showFieldError(field, "This field is required");
return false;
}
// Email validation
if (fieldType === "email" && value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
this.showFieldError(field, "Please enter a valid email address");
return false;
}
}
// Password validation
if (fieldType === "password" && value) {
if (value.length < 6) {
this.showFieldError(
field,
"Password must be at least 6 characters long"
);
return false;
}
}
return true;
}
showFieldError(field, message) {
field.classList.add("error");
// Remove existing error message
const existingError = field.parentNode.querySelector(".field-error");
if (existingError) {
existingError.remove();
}
// Add new error message
const errorElement = document.createElement("div");
errorElement.className = "field-error";
errorElement.textContent = message;
field.parentNode.appendChild(errorElement);
}
clearFieldError(field) {
field.classList.remove("error");
const errorElement = field.parentNode.querySelector(".field-error");
if (errorElement) {
errorElement.remove();
}
}
}
// Initialize when DOM is loaded
document.addEventListener("DOMContentLoaded", function () {
// Initialize managers
window.navigationManager = new NavigationManager();
window.modalManager = new ModalManager();
window.themeManager = new ThemeManager();
// Initialize search if search input exists
const searchInput =
document.getElementById("searchInput") ||
document.getElementById("qrSearch") ||
document.getElementById("searchUsers");
if (searchInput) {
const containerId = searchInput.dataset.container || "searchResults";
window.searchManager = new SearchManager(searchInput.id, containerId);
}
// Initialize form validation for forms with validation class
const forms = document.querySelectorAll(".validate-form");
forms.forEach((form) => {
new FormValidator(form.id);
});
// Global function assignments for inline event handlers
window.downloadQR = DownloadManager.downloadQR;
window.downloadModalQR = DownloadManager.downloadModalQR;
window.showToast = QRManager.utils.showToast;
});
// Global utility functions
window.QRManager = QRManager;
+739
View File
@@ -0,0 +1,739 @@
/**
* Users management JavaScript functionality
* static/js/users.js
*/
class UsersManager {
constructor() {
this.selectedUsers = new Set();
this.init();
}
init() {
this.initializeSearch();
this.initializeFilters();
this.initializeBulkActions();
this.setupEventListeners();
this.initializeModals();
}
// Initialize search functionality
initializeSearch() {
const searchInput = document.getElementById("searchUsers");
if (!searchInput) return;
let searchTimeout;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.filterUsers();
}, 300);
});
}
// Initialize filter functionality
initializeFilters() {
const filters = ["roleFilter", "statusFilter"];
filters.forEach((filterId) => {
const filter = document.getElementById(filterId);
if (filter) {
filter.addEventListener("change", () => {
this.filterUsers();
});
}
});
}
// Initialize bulk actions
initializeBulkActions() {
const selectAllCheckbox = document.getElementById("selectAllUsers");
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener("change", (e) => {
this.toggleSelectAll(e.target.checked);
});
}
// Individual checkbox handlers
const userCheckboxes = document.querySelectorAll(".user-checkbox");
userCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", (e) => {
this.handleUserSelection(e.target);
});
});
// Bulk action buttons
this.setupBulkActionButtons();
}
setupBulkActionButtons() {
const bulkDeactivateBtn = document.getElementById("bulkDeactivateBtn");
const bulkActivateBtn = document.getElementById("bulkActivateBtn");
const bulkDeleteBtn = document.getElementById("bulkDeleteBtn");
if (bulkDeactivateBtn) {
bulkDeactivateBtn.addEventListener("click", () => {
this.bulkDeactivateUsers();
});
}
if (bulkActivateBtn) {
bulkActivateBtn.addEventListener("click", () => {
this.bulkActivateUsers();
});
}
if (bulkDeleteBtn) {
bulkDeleteBtn.addEventListener("click", () => {
this.bulkDeleteUsers();
});
}
}
// Setup event listeners
setupEventListeners() {
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// ESC to close modals
if (e.key === "Escape") {
this.closeAllModals();
}
// Ctrl/Cmd + F to focus search
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
const searchInput = document.getElementById("searchUsers");
if (searchInput) searchInput.focus();
}
});
// Click outside dropdowns to close
document.addEventListener("click", (e) => {
if (!e.target.closest(".dropdown")) {
this.closeAllDropdowns();
}
});
}
// Initialize modal functionality
initializeModals() {
const modals = document.querySelectorAll(".modal");
modals.forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
this.closeModal(modal);
}
});
});
}
// Filter users based on search and filters
filterUsers() {
const searchTerm =
document.getElementById("searchUsers")?.value.toLowerCase() || "";
const roleFilter = document.getElementById("roleFilter")?.value || "";
const statusFilter = document.getElementById("statusFilter")?.value || "";
const userRows = document.querySelectorAll(".user-row");
let visibleCount = 0;
userRows.forEach((row) => {
const name = row.dataset.name?.toLowerCase() || "";
const email = row.dataset.email?.toLowerCase() || "";
const username = row.dataset.username?.toLowerCase() || "";
const role = row.dataset.role || "";
const status = row.dataset.status || "";
const matchesSearch =
!searchTerm ||
name.includes(searchTerm) ||
email.includes(searchTerm) ||
username.includes(searchTerm);
const matchesRole = !roleFilter || role === roleFilter;
const matchesStatus = !statusFilter || status === statusFilter;
if (matchesSearch && matchesRole && matchesStatus) {
this.showUserRow(row);
visibleCount++;
} else {
this.hideUserRow(row);
}
});
this.updateResultsCount(visibleCount);
}
showUserRow(row) {
row.style.display = "table-row";
row.classList.remove("fade-out");
row.classList.add("fade-in");
}
hideUserRow(row) {
row.classList.remove("fade-in");
row.classList.add("fade-out");
setTimeout(() => {
if (row.classList.contains("fade-out")) {
row.style.display = "none";
}
}, 300);
}
updateResultsCount(count) {
const counter = document.querySelector(".results-counter");
if (counter) {
counter.textContent = `${count} users found`;
}
}
// Dropdown management
toggleDropdown(event, button) {
event.stopPropagation();
const dropdown = button.closest(".dropdown");
const menu = dropdown.querySelector(".dropdown-menu");
// Close all other dropdowns
this.closeAllDropdowns();
// Toggle current dropdown
menu.classList.toggle("show");
}
closeAllDropdowns() {
const openMenus = document.querySelectorAll(".dropdown-menu.show");
openMenus.forEach((menu) => {
menu.classList.remove("show");
});
}
// User Actions
async deactivateUser(userId, userName) {
const confirmed = await this.showConfirmation(
"Deactivate User",
`Are you sure you want to deactivate ${userName}?`,
"This will disable their login access but preserve their data."
);
if (!confirmed) return;
try {
const response = await fetch(`/users/${userId}/delete`, {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (response.ok) {
// Update UI
this.updateUserStatus(userId, "inactive");
window.showToast(
`User ${userName} deactivated successfully`,
"success"
);
} else {
throw new Error("Failed to deactivate user");
}
} catch (error) {
console.error("Deactivation error:", error);
window.showToast("Failed to deactivate user", "error");
}
}
async reactivateUser(userId, userName) {
try {
const response = await fetch(`/users/${userId}/reactivate`, {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (response.ok) {
this.updateUserStatus(userId, "active");
window.showToast(
`User ${userName} reactivated successfully`,
"success"
);
} else {
throw new Error("Failed to reactivate user");
}
} catch (error) {
console.error("Reactivation error:", error);
window.showToast("Failed to reactivate user", "error");
}
}
async promoteUser(userId, userName) {
const confirmed = await this.showConfirmation(
"Promote to Admin",
`Promote ${userName} to admin?`,
"This will give them full system access including user management and system settings."
);
if (!confirmed) return;
try {
const response = await fetch(`/users/${userId}/promote`, {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (response.ok) {
this.updateUserRole(userId, "admin");
window.showToast(
`${userName} promoted to admin successfully`,
"success"
);
} else {
throw new Error("Failed to promote user");
}
} catch (error) {
console.error("Promotion error:", error);
window.showToast("Failed to promote user", "error");
}
}
async demoteUser(userId, userName) {
const confirmed = await this.showConfirmation(
"Demote from Admin",
`Demote ${userName} from admin to staff?`,
"This will remove their admin privileges and limit access to QR code management only."
);
if (!confirmed) return;
try {
const response = await fetch(`/users/${userId}/demote`, {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (response.ok) {
this.updateUserRole(userId, "staff");
window.showToast(
`${userName} demoted to staff successfully`,
"success"
);
} else {
throw new Error("Failed to demote user");
}
} catch (error) {
console.error("Demotion error:", error);
window.showToast("Failed to demote user", "error");
}
}
async permanentlyDeleteUser(userId, userName) {
const confirmed = await this.showConfirmation(
"Permanently Delete User",
`⚠️ PERMANENTLY DELETE ${userName}?`,
"This action CANNOT be undone and will permanently remove the user account and all associated QR codes.",
"danger"
);
if (!confirmed) return;
try {
const response = await fetch(`/users/${userId}/permanently-delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
});
if (response.ok) {
// Remove user row from table
const userRow = document.querySelector(`[data-user-id="${userId}"]`);
if (userRow) {
userRow.classList.add("fade-out");
setTimeout(() => userRow.remove(), 300);
}
window.showToast(`User ${userName} permanently deleted`, "success");
} else {
throw new Error("Failed to delete user");
}
} catch (error) {
console.error("Deletion error:", error);
window.showToast("Failed to delete user", "error");
}
}
// Update UI after user actions
updateUserStatus(userId, newStatus) {
const userRow = document.querySelector(`[data-user-id="${userId}"]`);
if (!userRow) return;
userRow.dataset.status = newStatus;
const statusBadge = userRow.querySelector(".user-status");
if (statusBadge) {
statusBadge.className = `user-status ${newStatus}`;
statusBadge.innerHTML = `
<i class="fas ${
newStatus === "active" ? "fa-check-circle" : "fa-times-circle"
}"></i>
${newStatus === "active" ? "Active" : "Inactive"}
`;
}
// Update action buttons in dropdown
this.updateUserActions(userId, newStatus);
}
updateUserRole(userId, newRole) {
const userRow = document.querySelector(`[data-user-id="${userId}"]`);
if (!userRow) return;
userRow.dataset.role = newRole;
const roleBadge = userRow.querySelector(".user-role");
if (roleBadge) {
roleBadge.className = `user-role ${newRole}`;
roleBadge.textContent = newRole;
}
// Update action buttons
this.updateUserActions(userId, null, newRole);
}
updateUserActions(userId, status = null, role = null) {
const userRow = document.querySelector(`[data-user-id="${userId}"]`);
if (!userRow) return;
const currentStatus = status || userRow.dataset.status;
const currentRole = role || userRow.dataset.role;
// Update dropdown menu items
const dropdownMenu = userRow.querySelector(".dropdown-menu");
if (dropdownMenu) {
// This would update the dropdown items based on new status/role
// Implementation depends on your dropdown structure
}
}
// Bulk Actions
toggleSelectAll(checked) {
const userCheckboxes = document.querySelectorAll(".user-checkbox");
userCheckboxes.forEach((checkbox) => {
checkbox.checked = checked;
this.handleUserSelection(checkbox);
});
}
handleUserSelection(checkbox) {
const userId = checkbox.value;
if (checkbox.checked) {
this.selectedUsers.add(userId);
} else {
this.selectedUsers.delete(userId);
}
this.updateBulkActionsBar();
this.updateSelectAllState();
}
updateBulkActionsBar() {
const bulkActionsBar = document.getElementById("bulkActionsBar");
const selectedCount = document.getElementById("selectedCount");
if (bulkActionsBar && selectedCount) {
if (this.selectedUsers.size > 0) {
bulkActionsBar.classList.add("show");
selectedCount.textContent = this.selectedUsers.size;
} else {
bulkActionsBar.classList.remove("show");
}
}
}
updateSelectAllState() {
const selectAllCheckbox = document.getElementById("selectAllUsers");
const userCheckboxes = document.querySelectorAll(".user-checkbox");
if (selectAllCheckbox && userCheckboxes.length > 0) {
const checkedCount = Array.from(userCheckboxes).filter(
(cb) => cb.checked
).length;
selectAllCheckbox.checked = checkedCount === userCheckboxes.length;
selectAllCheckbox.indeterminate =
checkedCount > 0 && checkedCount < userCheckboxes.length;
}
}
async bulkDeactivateUsers() {
if (this.selectedUsers.size === 0) return;
const confirmed = await this.showConfirmation(
"Bulk Deactivate Users",
`Deactivate ${this.selectedUsers.size} selected users?`,
"This will disable their login access but preserve their data."
);
if (!confirmed) return;
try {
const response = await fetch("/users/bulk/deactivate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: JSON.stringify({
user_ids: Array.from(this.selectedUsers),
}),
});
const result = await response.json();
if (result.success) {
// Update UI for deactivated users
this.selectedUsers.forEach((userId) => {
this.updateUserStatus(userId, "inactive");
});
this.clearSelection();
window.showToast(result.message, "success");
} else {
throw new Error(result.message);
}
} catch (error) {
console.error("Bulk deactivation error:", error);
window.showToast("Failed to deactivate users", "error");
}
}
async bulkActivateUsers() {
if (this.selectedUsers.size === 0) return;
const confirmed = await this.showConfirmation(
"Bulk Activate Users",
`Activate ${this.selectedUsers.size} selected users?`,
"This will restore their login access."
);
if (!confirmed) return;
try {
const response = await fetch("/users/bulk/activate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: JSON.stringify({
user_ids: Array.from(this.selectedUsers),
}),
});
const result = await response.json();
if (result.success) {
this.selectedUsers.forEach((userId) => {
this.updateUserStatus(userId, "active");
});
this.clearSelection();
window.showToast(result.message, "success");
} else {
throw new Error(result.message);
}
} catch (error) {
console.error("Bulk activation error:", error);
window.showToast("Failed to activate users", "error");
}
}
async bulkDeleteUsers() {
if (this.selectedUsers.size === 0) return;
const confirmed = await this.showConfirmation(
"Permanently Delete Users",
`⚠️ PERMANENTLY DELETE ${this.selectedUsers.size} selected users?`,
"This action CANNOT be undone and will permanently remove all user accounts and their associated data.",
"danger"
);
if (!confirmed) return;
try {
const response = await fetch("/users/bulk/permanently-delete", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: JSON.stringify({
user_ids: Array.from(this.selectedUsers),
}),
});
const result = await response.json();
if (result.success) {
// Remove users from table
this.selectedUsers.forEach((userId) => {
const userRow = document.querySelector(`[data-user-id="${userId}"]`);
if (userRow) {
userRow.classList.add("fade-out");
setTimeout(() => userRow.remove(), 300);
}
});
this.clearSelection();
window.showToast(result.message, "success");
} else {
throw new Error(result.message);
}
} catch (error) {
console.error("Bulk deletion error:", error);
window.showToast("Failed to delete users", "error");
}
}
clearSelection() {
this.selectedUsers.clear();
const userCheckboxes = document.querySelectorAll(".user-checkbox");
userCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
this.updateBulkActionsBar();
this.updateSelectAllState();
}
// Modal and confirmation dialogs
showConfirmation(title, message, details = "", type = "warning") {
return new Promise((resolve) => {
const modal = document.createElement("div");
modal.className = "modal";
modal.style.display = "flex";
modal.innerHTML = `
<div class="modal-content confirmation-modal">
<div class="modal-header">
<h3>
<i class="fas ${
type === "danger"
? "fa-exclamation-triangle text-danger"
: "fa-question-circle text-warning"
}"></i>
${title}
</h3>
</div>
<div class="modal-body">
<p><strong>${message}</strong></p>
${details ? `<p class="text-muted">${details}</p>` : ""}
</div>
<div class="modal-footer">
<button class="btn btn-secondary cancel-btn">Cancel</button>
<button class="btn btn-${
type === "danger" ? "danger" : "warning"
} confirm-btn">
<i class="fas fa-check"></i> Confirm
</button>
</div>
</div>
`;
document.body.appendChild(modal);
const cancelBtn = modal.querySelector(".cancel-btn");
const confirmBtn = modal.querySelector(".confirm-btn");
const cleanup = () => modal.remove();
cancelBtn.addEventListener("click", () => {
cleanup();
resolve(false);
});
confirmBtn.addEventListener("click", () => {
cleanup();
resolve(true);
});
modal.addEventListener("click", (e) => {
if (e.target === modal) {
cleanup();
resolve(false);
}
});
});
}
closeModal(modal) {
modal.style.display = "none";
}
closeAllModals() {
const modals = document.querySelectorAll('.modal[style*="flex"]');
modals.forEach((modal) => this.closeModal(modal));
}
// User details modal
showUserDetails(userId) {
// Implementation for showing user details modal
const modal = document.getElementById("userDetailsModal");
if (modal) {
// Populate modal with user data
modal.style.display = "flex";
}
}
closeUserDetailsModal() {
const modal = document.getElementById("userDetailsModal");
if (modal) {
modal.style.display = "none";
}
}
// Password reset modal
showPasswordResetModal(userId) {
const modal = document.getElementById("passwordResetModal");
if (modal) {
modal.style.display = "flex";
}
}
closePasswordResetModal() {
const modal = document.getElementById("passwordResetModal");
if (modal) {
modal.style.display = "none";
}
}
}
// Initialize users manager when DOM is loaded
document.addEventListener("DOMContentLoaded", function () {
window.usersManager = new UsersManager();
// Global functions for inline event handlers
window.toggleDropdown = (event, button) =>
window.usersManager.toggleDropdown(event, button);
window.deactivateUser = (userId, userName) =>
window.usersManager.deactivateUser(userId, userName);
window.reactivateUser = (userId, userName) =>
window.usersManager.reactivateUser(userId, userName);
window.promoteUser = (userId, userName) =>
window.usersManager.promoteUser(userId, userName);
window.demoteUser = (userId, userName) =>
window.usersManager.demoteUser(userId, userName);
window.permanentlyDeleteUser = (userId, userName) =>
window.usersManager.permanentlyDeleteUser(userId, userName);
window.showUserDetails = (userId) =>
window.usersManager.showUserDetails(userId);
window.closeUserDetailsModal = () =>
window.usersManager.closeUserDetailsModal();
window.showPasswordResetModal = (userId) =>
window.usersManager.showPasswordResetModal(userId);
window.closePasswordResetModal = () =>
window.usersManager.closePasswordResetModal();
});
+327
View File
@@ -0,0 +1,327 @@
{% extends "base.html" %}
{% block title %}Attendance Report - QR Code Management{% endblock %}
{% block content %}
<div class="attendance-page">
<div class="attendance-header">
<div class="header-content">
<h1>
<i class="fas fa-chart-line"></i>
Attendance Report
</h1>
<p>Monitor and analyze staff attendance across all locations</p>
</div>
<div class="header-actions">
<button onclick="exportAttendance()" class="btn btn-success">
<i class="fas fa-download"></i>
Export Data
</button>
<button onclick="refreshReport()" class="btn btn-secondary">
<i class="fas fa-sync-alt"></i>
Refresh
</button>
</div>
</div>
<!-- Statistics Cards -->
<div class="stats-section">
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-icon">
<i class="fas fa-user-check"></i>
</div>
<div class="stat-content">
<h3>{{ stats.total_checkins or 0 }}</h3>
<p>Total Check-ins</p>
<span class="stat-trend">All time</span>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div class="stat-content">
<h3>{{ stats.unique_employees or 0 }}</h3>
<p>Unique Employees</p>
<span class="stat-trend">Have checked in</span>
</div>
</div>
<div class="stat-card info">
<div class="stat-icon">
<i class="fas fa-map-marker-alt"></i>
</div>
<div class="stat-content">
<h3>{{ stats.active_locations or 0 }}</h3>
<p>Active Locations</p>
<span class="stat-trend">With check-ins</span>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon">
<i class="fas fa-calendar-day"></i>
</div>
<div class="stat-content">
<h3>{{ stats.today_checkins or 0 }}</h3>
<p>Today's Check-ins</p>
<span class="stat-trend">{{ "now"|strftime('%B %d') }}</span>
</div>
</div>
</div>
</div>
<!-- Filters Section -->
<div class="filters-section">
<div class="filters-card">
<div class="filters-header">
<h3>
<i class="fas fa-filter"></i>
Filter Records
</h3>
</div>
<form method="GET" class="filters-form" id="filtersForm">
<div class="filter-row">
<div class="filter-group">
<label for="date">
<i class="fas fa-calendar"></i>
Date
</label>
<input type="date"
id="date"
name="date"
value="{{ date_filter }}"
max="{{ 'now'|strftime('%Y-%m-%d') }}">
</div>
<div class="filter-group">
<label for="location">
<i class="fas fa-building"></i>
Location
</label>
<select id="location" name="location">
<option value="">All Locations</option>
{% for location in locations %}
<option value="{{ location }}"
{{ 'selected' if location == location_filter else '' }}>
{{ location }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="employee">
<i class="fas fa-id-badge"></i>
Employee ID
</label>
<input type="text"
id="employee"
name="employee"
value="{{ employee_filter }}"
placeholder="Enter Employee ID">
</div>
<div class="filter-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
Apply Filters
</button>
<button type="button" onclick="clearFilters()" class="btn btn-outline">
<i class="fas fa-times"></i>
Clear
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Attendance Table -->
<div class="attendance-table-section">
<div class="table-header">
<h3>
<i class="fas fa-list"></i>
Attendance Records
</h3>
<div class="table-controls">
<div class="entries-per-page">
<label>Show:</label>
<select id="entriesPerPage" onchange="changeEntriesPerPage()">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="all">All</option>
</select>
<span>entries</span>
</div>
</div>
</div>
<div class="table-container">
{% if attendance_records %}
<table class="attendance-table" id="attendanceTable">
<thead>
<tr>
<th onclick="sortTable(0)">#</th>
<th onclick="sortTable(1)">Employee ID <i class="fas fa-sort"></i></th>
<th onclick="sortTable(2)">Location <i class="fas fa-sort"></i></th>
<th onclick="sortTable(3)">Event <i class="fas fa-sort"></i></th>
<th onclick="sortTable(4)">Date <i class="fas fa-sort"></i></th>
<th onclick="sortTable(5)">Time <i class="fas fa-sort"></i></th>
<th onclick="sortTable(6)">Device <i class="fas fa-sort"></i></th>
<th onclick="sortTable(7)">Status <i class="fas fa-sort"></i></th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for record in attendance_records %}
<tr data-record-id="{{ record.id }}">
<td>{{ loop.index }}</td>
<td>
<div class="employee-info">
<span class="employee-id">{{ record.employee_id }}</span>
</div>
</td>
<td>
<div class="location-info">
<i class="fas fa-map-marker-alt"></i>
{{ record.location_name }}
</div>
</td>
<td>
<div class="event-info">
{{ record.location_event }}
</div>
</td>
<td>
<div class="date-info">
{{ record.check_in_date.strftime('%Y-%m-%d') }}
</div>
</td>
<td>
<div class="time-info">
{{ record.check_in_time.strftime('%H:%M') }}
</div>
</td>
<td>
<div class="device-info">
<i class="fas fa-mobile-alt"></i>
<span title="{{ record.device_info }}">
{{ record.device_info[:20] }}{% if record.device_info|length > 20 %}...{% endif %}
</span>
</div>
</td>
<td>
<span class="status-badge {{ record.status }}">
<i class="fas {{ 'fa-check-circle' if record.status == 'present' else 'fa-times-circle' }}"></i>
{{ record.status.title() }}
</span>
</td>
<td>
<div class="record-actions">
<button onclick="viewRecordDetails({{ record.id }})"
class="action-btn btn-view"
title="View Details">
<i class="fas fa-eye"></i>
</button>
<button onclick="editRecord({{ record.id }})"
class="action-btn btn-edit"
title="Edit Record">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteRecord({{ record.id }})"
class="action-btn btn-delete"
title="Delete Record">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="empty-icon">
<i class="fas fa-clipboard-list"></i>
</div>
<h3>No Attendance Records Found</h3>
<p>{% if date_filter or location_filter or employee_filter %}
No records match your current filters. Try adjusting the filter criteria.
{% else %}
No staff have checked in yet. QR codes need to be scanned to generate attendance data.
{% endif %}</p>
<button onclick="clearFilters()" class="btn btn-primary">
<i class="fas fa-refresh"></i>
Clear Filters
</button>
</div>
{% endif %}
</div>
<!-- Pagination -->
<div class="pagination-container" id="paginationContainer">
<!-- Pagination will be dynamically generated -->
</div>
</div>
<!-- Charts Section -->
<div class="charts-section">
<div class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<h3>
<i class="fas fa-chart-bar"></i>
Daily Check-ins (Last 7 Days)
</h3>
</div>
<div class="chart-container">
<canvas id="dailyChart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-header">
<h3>
<i class="fas fa-chart-pie"></i>
Check-ins by Location
</h3>
</div>
<div class="chart-container">
<canvas id="locationChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Record Details Modal -->
<div id="recordModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Attendance Record Details</h3>
<button class="modal-close" onclick="closeRecordModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body" id="modalBody">
<!-- Dynamic content will be loaded here -->
</div>
<div class="modal-footer">
<button onclick="closeRecordModal()" class="btn btn-secondary">
Close
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<script src="{{ url_for('static', filename='js/attendance_report.js') }}"></script>
{% endblock %}
+144
View File
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}QR Code Management System{% endblock %}</title>
<!-- Main CSS -->
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}"
/>
<!-- Font Awesome -->
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
rel="stylesheet"
/>
<!-- Page-specific CSS -->
{% block extra_head %}{% endblock %}
<!-- Favicon -->
<link
rel="icon"
type="image/x-icon"
href="{{ url_for('static', filename='favicon.ico') }}"
/>
</head>
<body>
<!-- Navigation Bar -->
{% if session.user_id %}
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">
<i class="fas fa-qrcode"></i>
<span>QR Manager</span>
</div>
<div class="nav-menu" id="navMenu">
<a href="{{ url_for('dashboard') }}" class="nav-link">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
{% if session.role == 'admin' %}
<a href="{{ url_for('users') }}" class="nav-link">
<i class="fas fa-users"></i> Users
</a>
<a href="{{ url_for('attendance_report') }}" class="nav-link">
<i class="fas fa-chart-line"></i> Reports
</a>
{% endif %}
<a href="{{ url_for('create_qr_code') }}" class="nav-link">
<i class="fas fa-plus"></i> Create QR
</a>
<a href="{{ url_for('profile') }}" class="nav-link">
<i class="fas fa-user"></i> Profile
</a>
<!-- Theme Toggle -->
<button
id="themeToggle"
class="nav-link"
style="background: none; border: none"
>
<i class="fas fa-moon"></i>
</button>
<a href="{{ url_for('logout') }}" class="nav-link logout">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
</div>
<!-- Mobile Menu Button -->
<div class="nav-toggle" id="mobile-menu">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</div>
</div>
</nav>
{% endif %}
<!-- Main Content -->
<main class="main-content">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %} {% if
messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
<i
class="fas {% if category == 'success' %}fa-check-circle{% elif category == 'error' %}fa-exclamation-circle{% elif category == 'info' %}fa-info-circle{% else %}fa-exclamation-triangle{% endif %}"
></i>
{{ message }}
<button
class="alert-close"
onclick="this.parentElement.style.display='none'"
>
<i class="fas fa-times"></i>
</button>
</div>
{% endfor %}
</div>
{% endif %} {% endwith %}
<!-- Page Content -->
<div class="container">{% block content %}{% endblock %}</div>
</main>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-content">
<p>&copy; 2025 QR Code Management System. All rights reserved.</p>
<div class="footer-links">
<a href="#" class="footer-link">Privacy Policy</a>
<a href="#" class="footer-link">Terms of Service</a>
<a href="#" class="footer-link">Support</a>
</div>
</div>
</div>
</footer>
<!-- Main JavaScript -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
<!-- Page-specific JavaScript -->
{% block extra_scripts %}{% endblock %}
<!-- Global JavaScript Variables -->
<script>
// Pass Flask session data to JavaScript
window.qrConfig = {
currentUserId: {{ session.user_id|default('null') }},
currentUserRole: '{{ session.role|default('') }}',
currentUserName: '{{ session.full_name|default('') }}',
csrfToken: '{{ csrf_token() if csrf_token else '' }}'
};
</script>
</body>
</html>
+609
View File
@@ -0,0 +1,609 @@
{% extends "base.html" %} {% block title %}Confirm Delete - QR Code Management{%
endblock %} {% block extra_head %}
<style>
.confirmation-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 70vh;
padding: var(--spacing-8) 0;
}
.confirmation-card {
background: var(--white);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xl);
max-width: 600px;
width: 100%;
overflow: hidden;
border: 1px solid var(--gray-200);
}
.confirmation-header {
background: linear-gradient(135deg, var(--danger-color), #b91c1c);
color: var(--white);
padding: var(--spacing-8);
text-align: center;
position: relative;
}
.warning-icon {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.2);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--spacing-4);
font-size: 2rem;
backdrop-filter: blur(10px);
}
.confirmation-header h2 {
font-size: var(--font-size-2xl);
font-weight: 700;
margin: 0;
color: var(--white);
}
.confirmation-body {
padding: var(--spacing-8);
}
.qr-details-section {
display: flex;
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
align-items: flex-start;
}
.qr-preview {
flex-shrink: 0;
}
.qr-preview img {
width: 120px;
height: 120px;
border-radius: var(--radius-lg);
border: 2px solid var(--gray-200);
box-shadow: var(--shadow-md);
}
.deletion-details {
flex: 1;
}
.deletion-details h3 {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--gray-900);
margin-bottom: var(--spacing-4);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.detail-item {
display: flex;
margin-bottom: var(--spacing-3);
padding: var(--spacing-3);
background: var(--gray-50);
border-radius: var(--radius);
border-left: 4px solid var(--primary-color);
}
.detail-label {
font-weight: 600;
color: var(--gray-600);
min-width: 100px;
margin-right: var(--spacing-3);
font-size: var(--font-size-sm);
}
.detail-value {
color: var(--gray-900);
font-size: var(--font-size-sm);
}
.warning-message {
background: linear-gradient(135deg, var(--danger-light), #fecaca);
border: 2px solid var(--danger-color);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
margin-bottom: var(--spacing-6);
}
.warning-message p {
margin: 0 0 var(--spacing-3) 0;
color: var(--danger-color);
font-weight: 600;
}
.warning-message p:first-child {
font-size: var(--font-size-lg);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.warning-message ul {
margin: var(--spacing-4) 0 0 0;
padding-left: var(--spacing-6);
color: var(--gray-700);
}
.warning-message li {
margin-bottom: var(--spacing-2);
font-size: var(--font-size-sm);
}
.confirmation-actions {
background: var(--gray-50);
padding: var(--spacing-6);
display: flex;
gap: var(--spacing-4);
justify-content: flex-end;
border-top: 1px solid var(--gray-200);
}
.btn-delete-confirm {
background: var(--danger-color);
color: var(--white);
border: 2px solid var(--danger-color);
padding: var(--spacing-4) var(--spacing-8);
font-weight: 600;
position: relative;
overflow: hidden;
}
.btn-delete-confirm:hover {
background: #b91c1c;
border-color: #b91c1c;
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-delete-confirm::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s;
}
.btn-delete-confirm:hover::before {
left: 100%;
}
.qr-status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 600;
}
.qr-status-badge.active {
background: var(--success-light);
color: var(--success-color);
}
.qr-status-badge.inactive {
background: var(--danger-light);
color: var(--danger-color);
}
.countdown-timer {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-sm);
color: var(--gray-600);
margin-top: var(--spacing-4);
}
.countdown-number {
background: var(--primary-color);
color: var(--white);
width: 24px;
height: 24px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: var(--font-size-xs);
}
/* Responsive Design */
@media (max-width: 768px) {
.confirmation-page {
padding: var(--spacing-4);
min-height: 60vh;
}
.qr-details-section {
flex-direction: column;
text-align: center;
}
.qr-preview {
align-self: center;
}
.confirmation-actions {
flex-direction: column-reverse;
}
.confirmation-actions .btn {
width: 100%;
justify-content: center;
}
.detail-item {
flex-direction: column;
gap: var(--spacing-1);
}
.detail-label {
min-width: auto;
margin-right: 0;
font-weight: 700;
}
}
/* Animation for page entry */
.confirmation-card {
animation: slideInScale 0.4s ease-out;
}
@keyframes slideInScale {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
{% endblock %} {% block content %}
<div class="confirmation-page">
<div class="confirmation-card">
<div class="confirmation-header">
<div class="warning-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h2>Confirm QR Code Deletion</h2>
</div>
<div class="confirmation-body">
<div class="qr-details-section">
<div class="qr-preview">
<img
src="data:image/png;base64,{{ qr_code.qr_code_image }}"
alt="QR Code for {{ qr_code.name }}"
/>
</div>
<div class="deletion-details">
<h3>
{{ qr_code.name }}
<span
class="qr-status-badge {{ 'active' if qr_code.active_status else 'inactive' }}"
>
<i
class="fas {{ 'fa-check-circle' if qr_code.active_status else 'fa-times-circle' }}"
></i>
{{ 'Active' if qr_code.active_status else 'Inactive' }}
</span>
</h3>
<div class="detail-item">
<span class="detail-label">Location:</span>
<span class="detail-value">{{ qr_code.location }}</span>
</div>
{% if qr_code.location_address %}
<div class="detail-item">
<span class="detail-label">Address:</span>
<span class="detail-value">{{ qr_code.location_address }}</span>
</div>
{% endif %} {% if qr_code.location_event %}
<div class="detail-item">
<span class="detail-label">Event:</span>
<span class="detail-value">{{ qr_code.location_event }}</span>
</div>
{% endif %}
<div class="detail-item">
<span class="detail-label">Created:</span>
<span class="detail-value"
>{{ qr_code.created_date.strftime('%B %d, %Y at %H:%M') }}</span
>
</div>
<div class="detail-item">
<span class="detail-label">Created by:</span>
<span class="detail-value">{{ qr_code.creator.full_name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">QR Code ID:</span>
<span class="detail-value">#{{ qr_code.id }}</span>
</div>
</div>
</div>
<div class="warning-message">
<p>
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning: This action cannot be undone!</strong>
</p>
<p>Permanently deleting this QR code will:</p>
<ul>
<li>Remove the QR code completely from the database</li>
<li>Make the QR code link permanently inaccessible</li>
<li>Delete all associated scan data and statistics</li>
<li>Remove any references to this QR code in reports</li>
</ul>
<p style="margin-top: var(--spacing-4); font-style: italic">
<strong>Alternative:</strong> Consider deactivating the QR code
instead if you might need it later.
</p>
</div>
</div>
<div class="confirmation-actions">
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
Cancel & Go Back
</a>
<a
href="{{ url_for('deactivate_qr_code', qr_id=qr_code.id) }}"
class="btn btn-warning"
>
<i class="fas fa-pause"></i>
Deactivate Instead
</a>
<form method="POST" style="display: inline" id="deleteForm">
<button type="button" class="btn btn-delete-confirm" id="deleteBtn">
<i class="fas fa-trash"></i>
Delete Permanently
</button>
</form>
</div>
</div>
</div>
<!-- Enhanced Confirmation Modal -->
<div id="finalConfirmModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 style="color: var(--danger-color)">
<i class="fas fa-exclamation-triangle"></i>
Final Confirmation Required
</h3>
</div>
<div class="modal-body">
<p>
<strong
>Are you absolutely sure you want to delete "{{ qr_code.name
}}"?</strong
>
</p>
<p>
This action is <strong>irreversible</strong> and will permanently remove
all data associated with this QR code.
</p>
<div class="confirmation-input">
<label for="confirmText" class="form-label">
Type <strong>"DELETE {{ qr_code.name.upper() }}"</strong> to confirm:
</label>
<input
type="text"
id="confirmText"
class="form-input"
placeholder="Type DELETE {{ qr_code.name.upper() }}"
autocomplete="off"
/>
</div>
<div class="countdown-timer" id="countdownTimer" style="display: none">
<span class="countdown-number" id="countdownNumber">5</span>
<span
>Deletion will be enabled in
<span id="countdownText">5</span> seconds...</span
>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick="closeFinalConfirmModal()"
>
Cancel
</button>
<button type="button" class="btn btn-danger" id="finalDeleteBtn" disabled>
<i class="fas fa-trash"></i>
Confirm Deletion
</button>
</div>
</div>
</div>
{% endblock %} {% block extra_scripts %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const deleteBtn = document.getElementById("deleteBtn");
const deleteForm = document.getElementById("deleteForm");
const finalConfirmModal = document.getElementById("finalConfirmModal");
const confirmText = document.getElementById("confirmText");
const finalDeleteBtn = document.getElementById("finalDeleteBtn");
const countdownTimer = document.getElementById("countdownTimer");
const countdownNumber = document.getElementById("countdownNumber");
const countdownText = document.getElementById("countdownText");
const expectedText = "DELETE {{ qr_code.name.upper() }}";
let countdownInterval;
let countdownValue = 5;
// Show final confirmation modal
deleteBtn.addEventListener("click", function () {
finalConfirmModal.style.display = "flex";
setTimeout(() => finalConfirmModal.classList.add("show"), 10);
// Start countdown
startCountdown();
// Focus on input
setTimeout(() => confirmText.focus(), 100);
});
// Close modal function
window.closeFinalConfirmModal = function () {
finalConfirmModal.classList.remove("show");
setTimeout(() => {
finalConfirmModal.style.display = "none";
resetModal();
}, 300);
};
// Start countdown timer
function startCountdown() {
countdownValue = 5;
countdownTimer.style.display = "flex";
updateCountdownDisplay();
countdownInterval = setInterval(() => {
countdownValue--;
updateCountdownDisplay();
if (countdownValue <= 0) {
clearInterval(countdownInterval);
countdownTimer.style.display = "none";
checkConfirmationText();
}
}, 1000);
}
function updateCountdownDisplay() {
countdownNumber.textContent = countdownValue;
countdownText.textContent = countdownValue;
}
// Reset modal state
function resetModal() {
confirmText.value = "";
finalDeleteBtn.disabled = true;
countdownTimer.style.display = "none";
if (countdownInterval) {
clearInterval(countdownInterval);
}
}
// Check confirmation text
function checkConfirmationText() {
const isTextCorrect = confirmText.value.trim() === expectedText;
const isCountdownFinished = countdownValue <= 0;
finalDeleteBtn.disabled = !(isTextCorrect && isCountdownFinished);
if (isTextCorrect && isCountdownFinished) {
finalDeleteBtn.classList.add("btn-danger");
finalDeleteBtn.classList.remove("btn-secondary");
} else {
finalDeleteBtn.classList.remove("btn-danger");
finalDeleteBtn.classList.add("btn-secondary");
}
}
// Monitor text input
confirmText.addEventListener("input", function () {
if (countdownValue <= 0) {
checkConfirmationText();
}
});
// Handle final deletion
finalDeleteBtn.addEventListener("click", function () {
if (!finalDeleteBtn.disabled) {
// Show loading state
finalDeleteBtn.innerHTML =
'<i class="fas fa-spinner fa-spin"></i> Deleting...';
finalDeleteBtn.disabled = true;
// Submit the form
deleteForm.submit();
}
});
// Close modal on escape key
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && finalConfirmModal.style.display === "flex") {
closeFinalConfirmModal();
}
});
// Close modal when clicking outside
finalConfirmModal.addEventListener("click", function (e) {
if (e.target === finalConfirmModal) {
closeFinalConfirmModal();
}
});
});
</script>
<style>
.confirmation-input {
margin: var(--spacing-6) 0;
}
.confirmation-input .form-input {
font-family: "Courier New", monospace;
font-weight: 600;
letter-spacing: 1px;
}
.confirmation-input .form-input:focus {
border-color: var(--danger-color);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
#finalDeleteBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#finalDeleteBtn:not(:disabled) {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
}
}
</style>
{% endblock %}
File diff suppressed because it is too large Load Diff
+590
View File
@@ -0,0 +1,590 @@
{% extends "base.html" %} {% block title %}Create User - QR Code Management{%
endblock %} {% block content %}
<div class="form-page">
<div class="form-card">
<div class="form-header">
<h1>Create New User</h1>
<p>Add a new user to the QR management system</p>
</div>
<form method="POST" class="form" id="createUserForm">
<div class="form-row">
<div class="form-group">
<label for="full_name">
<i class="fas fa-id-card"></i>
Full Name
</label>
<input
type="text"
id="full_name"
name="full_name"
required
placeholder="Enter user's full name"
/>
<small class="form-help"
>Legal name as it should appear in the system</small
>
</div>
<div class="form-group">
<label for="email">
<i class="fas fa-envelope"></i>
Email Address
</label>
<input
type="email"
id="email"
name="email"
required
placeholder="user@company.com"
/>
<small class="form-help"
>Primary email for notifications and account recovery</small
>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
Username
</label>
<input
type="text"
id="username"
name="username"
required
placeholder="Enter username"
pattern="[a-zA-Z0-9_]+"
title="Username can only contain letters, numbers, and underscores"
/>
<small class="form-help"
>Must be unique. Letters, numbers, and underscores only</small
>
</div>
<div class="form-group">
<label for="role">
<i class="fas fa-user-shield"></i>
User Role
</label>
<select id="role" name="role" required>
<option value="">Select Role</option>
<option value="staff">Staff User</option>
<option value="admin">Administrator</option>
</select>
<small class="form-help"
>Staff can manage QR codes; Admin has full system access</small
>
</div>
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
Initial Password
</label>
<input
type="password"
id="password"
name="password"
required
minlength="6"
placeholder="Enter initial password"
/>
<div class="password-strength" id="passwordStrength"></div>
<small class="form-help"
>Minimum 6 characters. User should change on first login</small
>
</div>
<!-- Role Information Panel -->
<div class="info-panel" id="roleInfo" style="display: none">
<div class="info-header">
<i class="fas fa-info-circle"></i>
<span>Role Permissions</span>
</div>
<div class="info-content" id="roleContent">
<!-- Dynamic content based on selected role -->
</div>
</div>
<!-- User Creation Summary -->
<div class="creation-summary" id="creationSummary" style="display: none">
<h3>
<i class="fas fa-check-circle"></i>
User Creation Summary
</h3>
<div class="summary-grid">
<div class="summary-item">
<strong>Name:</strong> <span id="summaryName">-</span>
</div>
<div class="summary-item">
<strong>Email:</strong> <span id="summaryEmail">-</span>
</div>
<div class="summary-item">
<strong>Username:</strong> <span id="summaryUsername">-</span>
</div>
<div class="summary-item">
<strong>Role:</strong> <span id="summaryRole">-</span>
</div>
</div>
</div>
<div class="form-actions">
<a href="{{ url_for('users') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
Back to Users
</a>
<button type="button" class="btn btn-info" id="previewUser">
<i class="fas fa-eye"></i>
Preview User
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus"></i>
Create User
</button>
</div>
</form>
</div>
</div>
{% endblock %} {% block extra_scripts %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("createUserForm");
const roleSelect = document.getElementById("role");
const roleInfo = document.getElementById("roleInfo");
const roleContent = document.getElementById("roleContent");
const previewBtn = document.getElementById("previewUser");
const creationSummary = document.getElementById("creationSummary");
// Form inputs
const fullNameInput = document.getElementById("full_name");
const emailInput = document.getElementById("email");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const passwordStrength = document.getElementById("passwordStrength");
// Role information
const rolePermissions = {
staff: {
title: "Staff User Permissions",
permissions: [
"Create and edit QR codes",
"View all QR codes in the system",
"Download QR code images",
"Update personal profile information",
"Access dashboard and reports",
],
restrictions: [
"Cannot delete QR codes",
"Cannot manage other users",
"Cannot access admin settings",
],
},
admin: {
title: "Administrator Permissions",
permissions: [
"Full QR code management (create, edit, delete)",
"Complete user management capabilities",
"System configuration access",
"View all system analytics",
"Bulk operations and data export",
"Access to all admin features",
],
restrictions: ["With great power comes great responsibility!"],
},
};
// Show role information when role is selected
roleSelect.addEventListener("change", function () {
const selectedRole = this.value;
if (selectedRole && rolePermissions[selectedRole]) {
const permissions = rolePermissions[selectedRole];
roleContent.innerHTML = `
<h4>${permissions.title}</h4>
<div class="permissions-grid">
<div class="permissions-column">
<h5><i class="fas fa-check text-success"></i> Allowed Actions</h5>
<ul>
${permissions.permissions
.map((perm) => `<li>${perm}</li>`)
.join("")}
</ul>
</div>
<div class="permissions-column">
<h5><i class="fas fa-times text-danger"></i> Restrictions</h5>
<ul>
${permissions.restrictions
.map((rest) => `<li>${rest}</li>`)
.join("")}
</ul>
</div>
</div>
`;
roleInfo.style.display = "block";
setTimeout(() => roleInfo.classList.add("fade-in"), 10);
} else {
roleInfo.style.display = "none";
roleInfo.classList.remove("fade-in");
}
});
// Password strength checker
passwordInput.addEventListener("input", function () {
const password = this.value;
let strength = 0;
let feedback = [];
// Check password criteria
if (password.length >= 6) strength++;
else feedback.push("At least 6 characters");
if (/[A-Z]/.test(password)) strength++;
else feedback.push("One uppercase letter");
if (/[a-z]/.test(password)) strength++;
else feedback.push("One lowercase letter");
if (/[0-9]/.test(password)) strength++;
else feedback.push("One number");
if (/[^A-Za-z0-9]/.test(password)) strength++;
else feedback.push("One special character");
// Update strength display
let strengthText = "";
let strengthClass = "";
if (strength < 2) {
strengthText = "Weak";
strengthClass = "weak";
} else if (strength < 4) {
strengthText = "Medium";
strengthClass = "medium";
} else {
strengthText = "Strong";
strengthClass = "strong";
}
passwordStrength.className = `password-strength ${strengthClass}`;
passwordStrength.textContent = `Strength: ${strengthText}`;
if (feedback.length > 0 && password.length > 0) {
passwordStrength.textContent += ` (Missing: ${feedback.join(", ")})`;
}
});
// Username validation and formatting
usernameInput.addEventListener("input", function () {
// Remove invalid characters
this.value = this.value.replace(/[^a-zA-Z0-9_]/g, "");
// Convert to lowercase for consistency
this.value = this.value.toLowerCase();
});
// Auto-generate username from full name
fullNameInput.addEventListener("input", function () {
if (!usernameInput.value) {
const generatedUsername = this.value
.toLowerCase()
.replace(/[^a-zA-Z0-9\s]/g, "")
.replace(/\s+/g, "_")
.substring(0, 20);
usernameInput.value = generatedUsername;
}
});
// Preview user functionality
previewBtn.addEventListener("click", function () {
const formData = {
full_name: fullNameInput.value.trim(),
email: emailInput.value.trim(),
username: usernameInput.value.trim(),
role: roleSelect.value,
};
// Validate required fields
if (
!formData.full_name ||
!formData.email ||
!formData.username ||
!formData.role
) {
alert("Please fill in all required fields before previewing.");
return;
}
// Update summary
document.getElementById("summaryName").textContent = formData.full_name;
document.getElementById("summaryEmail").textContent = formData.email;
document.getElementById("summaryUsername").textContent =
formData.username;
document.getElementById("summaryRole").textContent =
formData.role.charAt(0).toUpperCase() + formData.role.slice(1);
// Show summary
creationSummary.style.display = "block";
setTimeout(() => creationSummary.classList.add("fade-in"), 10);
// Scroll to summary
creationSummary.scrollIntoView({
behavior: "smooth",
block: "center",
});
});
// Form validation
form.addEventListener("submit", function (e) {
const fullName = fullNameInput.value.trim();
const email = emailInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
const role = roleSelect.value;
// Comprehensive validation
if (!fullName || fullName.length < 2) {
e.preventDefault();
alert("Full name must be at least 2 characters long.");
fullNameInput.focus();
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email || !emailRegex.test(email)) {
e.preventDefault();
alert("Please enter a valid email address.");
emailInput.focus();
return;
}
if (!username || username.length < 3) {
e.preventDefault();
alert("Username must be at least 3 characters long.");
usernameInput.focus();
return;
}
if (!password || password.length < 6) {
e.preventDefault();
alert("Password must be at least 6 characters long.");
passwordInput.focus();
return;
}
if (!role) {
e.preventDefault();
alert("Please select a user role.");
roleSelect.focus();
return;
}
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.innerHTML =
'<i class="fas fa-spinner fa-spin"></i> Creating User...';
submitBtn.disabled = true;
});
// Real-time email validation
emailInput.addEventListener("blur", function () {
const email = this.value.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const formGroup = this.closest(".form-group");
if (email && !emailRegex.test(email)) {
formGroup.classList.add("has-error");
if (!formGroup.querySelector(".error-message")) {
const errorMsg = document.createElement("div");
errorMsg.className = "error-message";
errorMsg.textContent = "Please enter a valid email address";
formGroup.appendChild(errorMsg);
}
} else {
formGroup.classList.remove("has-error");
const errorMsg = formGroup.querySelector(".error-message");
if (errorMsg) {
errorMsg.remove();
}
}
});
// Character counters
function addCharacterCounter(input, maxLength) {
const formGroup = input.closest(".form-group");
const counter = document.createElement("div");
counter.className = "character-counter";
function updateCounter() {
const currentLength = input.value.length;
counter.textContent = `${currentLength}${
maxLength ? "/" + maxLength : ""
} characters`;
if (maxLength && currentLength > maxLength * 0.8) {
counter.classList.add("warning");
} else {
counter.classList.remove("warning");
}
}
input.addEventListener("input", updateCounter);
formGroup.appendChild(counter);
updateCounter();
}
// Add character counters
addCharacterCounter(fullNameInput, 100);
addCharacterCounter(usernameInput, 50);
// Auto-capitalize full name
fullNameInput.addEventListener("input", function () {
this.value = this.value.replace(/\b\w/g, (l) => l.toUpperCase());
});
});
</script>
<style>
/* Additional styles for create user form */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.info-panel {
margin: 1.5rem 0;
padding: 1.5rem;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border: 1px solid #3b82f6;
border-radius: var(--radius-xl);
opacity: 0;
transform: translateY(20px);
transition: all 0.3s ease;
}
.info-panel.fade-in {
opacity: 1;
transform: translateY(0);
}
.info-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #1e40af;
margin-bottom: 1rem;
}
.permissions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.permissions-column h5 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
}
.permissions-column ul {
list-style: none;
padding: 0;
margin: 0;
}
.permissions-column li {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
font-size: 0.875rem;
color: #374151;
}
.permissions-column li:last-child {
border-bottom: none;
}
.text-success {
color: #059669 !important;
}
.text-danger {
color: #dc2626 !important;
}
.creation-summary {
margin: 2rem 0;
padding: 1.5rem;
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border: 2px solid #22c55e;
border-radius: var(--radius-xl);
opacity: 0;
transform: translateY(20px);
transition: all 0.3s ease;
}
.creation-summary.fade-in {
opacity: 1;
transform: translateY(0);
}
.creation-summary h3 {
display: flex;
align-items: center;
gap: 0.5rem;
color: #15803d;
margin-bottom: 1rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.summary-item {
padding: 0.75rem;
background: white;
border-radius: var(--radius);
font-size: 0.875rem;
}
.summary-item strong {
color: #374151;
margin-right: 0.5rem;
}
.summary-item span {
color: #059669;
font-weight: 500;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
gap: 1rem;
}
.permissions-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
+392
View File
@@ -0,0 +1,392 @@
{% extends "base.html" %} {% block title %}Dashboard - QR Code Management{%
endblock %} {% block extra_head %}
<!-- Dashboard-specific CSS -->
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/dashboard.css') }}"
/>
{% endblock %} {% block content %}
<div class="dashboard-container">
<!-- Dashboard Header -->
<div class="dashboard-header">
<div class="welcome-section">
<h1>Welcome back, {{ session.full_name }}!</h1>
<p>Manage your QR codes and monitor system activity</p>
<div class="user-info-badge">
<div class="role-indicator {{ session.role }}">
<i
class="fas {{ 'fa-crown' if session.role == 'admin' else 'fa-user' }}"
></i>
{{ session.role.title() }}
</div>
{% if session.last_login_date %}
<div class="last-login">
<i class="fas fa-clock"></i>
Last login: {{ session.last_login_date.strftime('%b %d, %Y at %H:%M')
}}
</div>
{% endif %}
</div>
</div>
<div class="quick-actions">
<a href="{{ url_for('create_qr_code') }}" class="btn btn-primary btn-lg">
<i class="fas fa-plus"></i>
Create QR Code
</a>
{% if session.role == 'admin' %}
<a href="{{ url_for('users') }}" class="btn btn-secondary btn-lg">
<i class="fas fa-users"></i>
Manage Users
</a>
{% endif %}
</div>
</div>
<!-- Statistics Section -->
{% if session.role == 'admin' %}
<div class="stats-section">
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-icon">
<i class="fas fa-qrcode"></i>
</div>
<div class="stat-content">
<h3>{{ qr_codes|length }}</h3>
<p>Total QR Codes</p>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-content">
<h3>
{{ qr_codes|selectattr('active_status', 'equalto', True)|list|length
}}
</h3>
<p>Active QR Codes</p>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div class="stat-content">
<h3>{{ total_users or 0 }}</h3>
<p>Total Users</p>
</div>
</div>
<div class="stat-card danger">
<div class="stat-icon">
<i class="fas fa-pause-circle"></i>
</div>
<div class="stat-content">
<h3>
{{ qr_codes|selectattr('active_status', 'equalto',
False)|list|length }}
</h3>
<p>Inactive QR Codes</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- QR Codes Section -->
<div class="qr-codes-section">
<div class="section-header">
<h2>Your QR Codes</h2>
<div class="section-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input
type="text"
id="qrSearch"
placeholder="Search QR codes..."
data-container="qrGrid"
/>
</div>
<select id="statusFilter" class="filter-select">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<div class="results-counter">{{ qr_codes|length }} results</div>
{% if session.role == 'admin' %}
<button id="expandAllToggle" class="btn btn-outline">
<i class="fas fa-expand-alt"></i>
Expand All
</button>
{% endif %}
</div>
</div>
{% if qr_codes %}
<!-- Bulk Actions Bar (initially hidden) -->
<div id="bulkActionsBar" class="bulk-actions-bar">
<div class="bulk-actions-info">
<i class="fas fa-check-square"></i>
<span id="selectedCount">0</span> QR codes selected
</div>
<div class="bulk-actions-buttons">
{% if session.role == 'admin' %}
<button id="bulkDeleteBtn" class="btn btn-danger btn-sm">
<i class="fas fa-trash"></i>
Delete Selected
</button>
{% endif %}
</div>
</div>
<!-- QR Codes Grid -->
<div class="qr-grid" id="qrGrid">
{% for qr in qr_codes %}
<div
class="qr-item"
data-qr-id="{{ qr.id }}"
data-name="{{ qr.name.lower() }}"
data-location="{{ qr.location.lower() }}"
data-status="{{ 'active' if qr.active_status else 'inactive' }}"
onclick="toggleQRItem(this)"
>
<!-- QR Item Header -->
<div class="qr-item-header">
{% if session.role == 'admin' %}
<input
type="checkbox"
class="qr-checkbox"
value="{{ qr.id }}"
onclick="event.stopPropagation()"
/>
{% endif %}
<div
class="qr-code-preview"
onclick="event.stopPropagation(); openQRModal({
name: '{{ qr.name }}',
image: '{{ qr.qr_code_image }}'
})"
>
<img
src="data:image/png;base64,{{ qr.qr_code_image }}"
alt="QR Code for {{ qr.name }}"
/>
</div>
<div class="qr-item-info">
<div class="qr-item-title">
{{ qr.name }}
<span
class="qr-status {{ 'active' if qr.active_status else 'inactive' }}"
>
<i
class="fas {{ 'fa-check-circle' if qr.active_status else 'fa-times-circle' }}"
></i>
{{ 'Active' if qr.active_status else 'Inactive' }}
</span>
</div>
<div class="qr-item-meta">
<span>
<i class="fas fa-calendar"></i>
{{ qr.created_date.strftime('%b %d, %Y') }}
</span>
<span>
<i class="fas fa-map-marker-alt"></i>
{{ qr.location }}
</span>
<span>
<i class="fas fa-user"></i>
{{ qr.creator.full_name }}
</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="qr-item-actions">
<div class="quick-actions">
{% if session.role == 'admin' %}
<button
class="action-btn btn-status {{ 'btn-deactivate' if qr.active_status else 'btn-activate' }}"
onclick="event.stopPropagation(); toggleQRCodeStatus('{{ qr.id }}')"
title="{{ 'Deactivate' if qr.active_status else 'Activate' }} QR Code"
id="toggle-btn-{{ qr.id }}"
>
<i
class="fas {{ 'fa-pause' if qr.active_status else 'fa-play' }}"
id="toggle-icon-{{ qr.id }}"
></i>
</button>
{% endif %}
<button
class="action-btn btn-download"
onclick="event.stopPropagation(); downloadQR('{{ qr.qr_code_image }}', '{{ qr.name }}')"
title="Download QR Code"
>
<i class="fas fa-download"></i>
</button>
<a
href="{{ url_for('edit_qr_code', qr_id=qr.id) }}"
class="action-btn btn-edit"
onclick="event.stopPropagation()"
title="Edit QR Code"
>
<i class="fas fa-edit"></i>
</a>
{% if session.role == 'admin' %}
<button
class="action-btn btn-delete"
onclick="event.stopPropagation(); deleteQRCode('{{ qr.id }}', '{{ qr.name }}')"
title="Delete QR Code"
>
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</div>
<!-- Expanded Details (initially hidden) -->
<div class="qr-item-details">
<div class="qr-details-content">
<div class="qr-details-info">
<div class="info-item">
<span class="label">Location Address:</span>
<span class="value"
>{{ qr.location_address or 'Not specified' }}</span
>
</div>
<div class="info-item">
<span class="label">Event:</span>
<span class="value"
>{{ qr.location_event or 'Not specified' }}</span
>
</div>
<div class="info-item">
<span class="label">Created by:</span>
<span class="value">{{ qr.creator.full_name }}</span>
</div>
<div class="info-item">
<span class="label">Created on:</span>
<span class="value"
>{{ qr.created_date.strftime('%B %d, %Y at %H:%M') }}</span
>
</div>
<div class="info-item">
<span class="label">QR ID:</span>
<span class="value">#{{ qr.id }}</span>
</div>
</div>
<div class="qr-details-preview">
<div
class="qr-details-image"
onclick="openQRModal({
name: '{{ qr.name }}',
image: '{{ qr.qr_code_image }}'
})"
>
<img
src="data:image/png;base64,{{ qr.qr_code_image }}"
alt="QR Code for {{ qr.name }}"
/>
</div>
</div>
</div>
<!-- Detail Actions -->
<div class="details-actions">
{% if session.role == 'admin' %}
<button
onclick="toggleQRCodeStatus({{ qr.id }})"
class="btn {{ 'btn-warning' if qr.active_status else 'btn-success' }}"
id="detail-toggle-btn-{{ qr.id }}"
>
<i
class="fas {{ 'fa-pause' if qr.active_status else 'fa-play' }}"
></i>
{{ 'Deactivate' if qr.active_status else 'Activate' }} QR Code
</button>
{% endif %}
<button
onclick="downloadQR('{{ qr.qr_code_image }}', '{{ qr.name }}')"
class="btn btn-primary"
>
<i class="fas fa-download"></i>
Download QR Code
</button>
<a
href="{{ url_for('edit_qr_code', qr_id=qr.id) }}"
class="btn btn-secondary"
>
<i class="fas fa-edit"></i>
Edit Details
</a>
<button
onclick="copyQRData('{{ qr.name }}', '{{ qr.location }}', '{{ qr.location_address }}', '{{ qr.location_event }}')"
class="btn btn-outline"
>
<i class="fas fa-copy"></i>
Copy Info
</button>
{% if session.role == 'admin' %}
<button
onclick="deleteQRCode({{ qr.id }}, '{{ qr.name }}')"
class="btn btn-danger"
>
<i class="fas fa-trash"></i>
Delete QR Code
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">
<i class="fas fa-qrcode"></i>
</div>
<h3>No QR Codes Yet</h3>
<p>Get started by creating your first QR code</p>
<a href="{{ url_for('create_qr_code') }}" class="btn btn-primary">
<i class="fas fa-plus"></i>
Create Your First QR Code
</a>
</div>
{% endif %}
</div>
</div>
<!-- QR Code Modal -->
<div id="qrModal" class="modal">
<div class="modal-content qr-modal">
<div class="modal-header">
<h3 id="modalTitle">QR Code Preview</h3>
<button class="modal-close" onclick="closeQRModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="qr-modal-image">
<img id="modalQRImage" src="" alt="QR Code" />
</div>
</div>
<div class="modal-footer">
<button onclick="downloadModalQR()" class="btn btn-primary">
<i class="fas fa-download"></i>
Download
</button>
<button onclick="closeQRModal()" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
{% endblock %} {% block extra_scripts %}
<!-- Dashboard-specific JavaScript -->
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+742
View File
@@ -0,0 +1,742 @@
{% extends "base.html" %}
{% block title %}Edit User - QR Code Management{% endblock %}
{% block content %}
<div class="form-page">
<div class="form-card">
<div class="form-header">
<h1>Edit User</h1>
<p>Update user information and permissions</p>
</div>
<!-- Current User Info Display -->
<div class="current-user-info">
<div class="user-avatar-section">
<div class="user-avatar large">
<i class="fas fa-user"></i>
<div class="avatar-status {{ user.role }}">
<i class="fas {{ 'fa-crown' if user.role == 'admin' else 'fa-user-tie' }}"></i>
</div>
</div>
<div class="user-basic-info">
<h3>{{ user.full_name }}</h3>
<p>@{{ user.username }}</p>
<span class="role-badge {{ user.role }}">
{{ user.role.title() }}
</span>
</div>
</div>
<div class="user-stats-quick">
<div class="stat-quick">
<span class="stat-number">{{ user.created_qr_codes.count() }}</span>
<span class="stat-label">QR Codes</span>
</div>
<div class="stat-quick">
<span class="stat-number">{{ user.created_date.strftime('%b %Y') }}</span>
<span class="stat-label">Joined</span>
</div>
<div class="stat-quick">
<span class="stat-number">
{% if user.last_login_date %}
{{ user.last_login_date.strftime('%m/%d') }}
{% else %}
Never
{% endif %}
</span>
<span class="stat-label">Last Login</span>
</div>
</div>
</div>
<form method="POST" class="form" id="editUserForm">
<div class="form-row">
<div class="form-group">
<label for="full_name">
<i class="fas fa-id-card"></i>
Full Name
</label>
<input type="text" id="full_name" name="full_name"
value="{{ user.full_name }}" required>
<small class="form-help">User's display name in the system</small>
</div>
<div class="form-group">
<label for="email">
<i class="fas fa-envelope"></i>
Email Address
</label>
<input type="email" id="email" name="email"
value="{{ user.email }}" required>
<small class="form-help">Primary email for notifications</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="username_display">
<i class="fas fa-user"></i>
Username
</label>
<input type="text" id="username_display"
value="{{ user.username }}" disabled>
<small class="form-help">Username cannot be changed</small>
</div>
<div class="form-group">
<label for="role">
<i class="fas fa-user-shield"></i>
User Role
</label>
<select id="role" name="role" required>
<option value="staff" {{ 'selected' if user.role == 'staff' else '' }}>Staff User</option>
<option value="admin" {{ 'selected' if user.role == 'admin' else '' }}>Administrator</option>
</select>
<small class="form-help">Changes role permissions immediately</small>
</div>
</div>
<!-- Role Change Warning -->
<div class="role-warning" id="roleWarning" style="display: none;">
<div class="warning-header">
<i class="fas fa-exclamation-triangle"></i>
<span>Role Change Warning</span>
</div>
<div class="warning-content" id="warningContent">
<!-- Dynamic warning content -->
</div>
</div>
<!-- Password Section -->
<div class="password-section">
<h3>
<i class="fas fa-key"></i>
Password Management
</h3>
<div class="form-group">
<label for="new_password">
<i class="fas fa-lock"></i>
New Password (Optional)
</label>
<input type="password" id="new_password" name="new_password"
minlength="6" placeholder="Leave blank to keep current password">
<div class="password-strength" id="passwordStrength"></div>
<small class="form-help">Only enter a password if you want to change it</small>
</div>
<div class="form-group">
<label for="confirm_password">
<i class="fas fa-check-circle"></i>
Confirm New Password
</label>
<input type="password" id="confirm_password" name="confirm_password">
<div class="password-match" id="passwordMatch"></div>
</div>
</div>
<!-- Account Status Section -->
<div class="status-section">
<h3>
<i class="fas fa-info-circle"></i>
Account Status
</h3>
<div class="status-display">
<div class="status-item">
<strong>Current Status:</strong>
<span class="status-badge {{ 'active' if user.active_status else 'inactive' }}">
<i class="fas {{ 'fa-check-circle' if user.active_status else 'fa-times-circle' }}"></i>
{{ 'Active' if user.active_status else 'Inactive' }}
</span>
</div>
<div class="status-item">
<strong>Account Created:</strong>
<span>{{ user.created_date.strftime('%B %d, %Y at %H:%M') }}</span>
</div>
{% if user.creator %}
<div class="status-item">
<strong>Created By:</strong>
<span>{{ user.creator.full_name }}</span>
</div>
{% endif %}
</div>
</div>
<div class="form-actions">
<a href="{{ url_for('users') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
Back to Users
</a>
{% if not user.active_status %}
<a href="{{ url_for('reactivate_user', user_id=user.id) }}"
class="btn btn-success"
onclick="return confirm('Reactivate this user account?')">
<i class="fas fa-user-check"></i>
Reactivate User
</a>
{% endif %}
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
Save Changes
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('editUserForm');
const roleSelect = document.getElementById('role');
const roleWarning = document.getElementById('roleWarning');
const warningContent = document.getElementById('warningContent');
const newPasswordInput = document.getElementById('new_password');
const confirmPasswordInput = document.getElementById('confirm_password');
const passwordStrength = document.getElementById('passwordStrength');
const passwordMatch = document.getElementById('passwordMatch');
const originalRole = '{{ user.role }}';
const isCurrentUser = {{ 'true' if user.id == session.user_id else 'false' }};
// Role change detection and warnings
roleSelect.addEventListener('change', function() {
const newRole = this.value;
if (newRole !== originalRole) {
let warningMessage = '';
if (originalRole === 'admin' && newRole === 'staff') {
if (isCurrentUser) {
warningMessage = `
<strong>⚠️ You are demoting yourself!</strong><br>
This will remove your admin privileges and you will lose access to:
<ul>
<li>User management</li>
<li>System administration</li>
<li>Advanced settings</li>
</ul>
<strong>You will need another admin to restore your privileges.</strong>
`;
} else {
warningMessage = `
<strong>Demoting Admin to Staff</strong><br>
This user will lose admin privileges and access to:
<ul>
<li>User management</li>
<li>System administration</li>
<li>Advanced settings</li>
</ul>
`;
}
} else if (originalRole === 'staff' && newRole === 'admin') {
warningMessage = `
<strong>Promoting Staff to Admin</strong><br>
This user will gain full system access including:
<ul>
<li>User management</li>
<li>System administration</li>
<li>All QR code operations</li>
</ul>
`;
}
warningContent.innerHTML = warningMessage;
roleWarning.style.display = 'block';
setTimeout(() => roleWarning.classList.add('fade-in'), 10);
} else {
roleWarning.style.display = 'none';
roleWarning.classList.remove('fade-in');
}
});
// Password strength checker
newPasswordInput.addEventListener('input', function() {
const password = this.value;
if (!password) {
passwordStrength.textContent = '';
passwordStrength.className = 'password-strength';
return;
}
let strength = 0;
let feedback = [];
if (password.length >= 6) strength++;
else feedback.push('At least 6 characters');
if (password.length >= 8) strength++;
else if (password.length >= 6) feedback.push('8+ characters recommended');
if (/[A-Z]/.test(password)) strength++;
else feedback.push('One uppercase letter');
if (/[a-z]/.test(password)) strength++;
else feedback.push('One lowercase letter');
if (/[0-9]/.test(password)) strength++;
else feedback.push('One number');
if (/[^A-Za-z0-9]/.test(password)) strength++;
else feedback.push('One special character');
let strengthText = '';
let strengthClass = '';
if (strength < 3) {
strengthText = 'Weak';
strengthClass = 'weak';
} else if (strength < 5) {
strengthText = 'Medium';
strengthClass = 'medium';
} else {
strengthText = 'Strong';
strengthClass = 'strong';
}
passwordStrength.className = `password-strength ${strengthClass}`;
passwordStrength.textContent = `Strength: ${strengthText}`;
if (feedback.length > 0) {
passwordStrength.textContent += ` (Missing: ${feedback.join(', ')})`;
}
checkPasswordMatch();
});
// Password match checker
function checkPasswordMatch() {
if (confirmPasswordInput.value && newPasswordInput.value) {
if (newPasswordInput.value === confirmPasswordInput.value) {
passwordMatch.textContent = '✓ Passwords match';
passwordMatch.className = 'password-match match';
} else {
passwordMatch.textContent = '✗ Passwords do not match';
passwordMatch.className = 'password-match no-match';
}
} else {
passwordMatch.textContent = '';
passwordMatch.className = 'password-match';
}
}
confirmPasswordInput.addEventListener('input', checkPasswordMatch);
// Form validation
form.addEventListener('submit', function(e) {
const newPassword = newPasswordInput.value;
const confirmPassword = confirmPasswordInput.value;
// Password validation if provided
if (newPassword) {
if (newPassword.length < 6) {
e.preventDefault();
alert('New password must be at least 6 characters long.');
newPasswordInput.focus();
return;
}
if (newPassword !== confirmPassword) {
e.preventDefault();
alert('New passwords do not match.');
confirmPasswordInput.focus();
return;
}
}
// Role change confirmation
const newRole = roleSelect.value;
if (newRole !== originalRole) {
const roleChangeConfirm = isCurrentUser && newRole === 'staff'
? confirm('⚠️ You are about to demote yourself from admin to staff. You will lose admin privileges. Are you sure?')
: confirm(`Change user role from ${originalRole} to ${newRole}?`);
if (!roleChangeConfirm) {
e.preventDefault();
return;
}
}
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving Changes...';
submitBtn.disabled = true;
});
// Auto-capitalize full name
const fullNameInput = document.getElementById('full_name');
fullNameInput.addEventListener('input', function() {
this.value = this.value.replace(/\b\w/g, l => l.toUpperCase());
});
});
</script>
<style>
/* Additional styles for edit user form */
.current-user-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding: 1.5rem;
background: linear-gradient(135deg, var(--gray-50), var(--white));
border-radius: var(--radius-xl);
border: 1px solid var(--gray-200);
}
.user-avatar-section {
display: flex;
align-items: center;
gap: 1rem;
}
.user-avatar.large {
width: 80px;
height: 80px;
background: var(--gray-200);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: var(--gray-600);
position: relative;
}
.avatar-status {
position: absolute;
bottom: -2px;
right: -2px;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
border: 2px solid var(--white);
}
.avatar-status.admin {
background: #fbbf24;
color: #92400e;
}
.avatar-status.staff {
background: var(--gray-400);
color: var(--white);
}
.user-basic-info h3 {
font-size: 1.25rem;
font-weight: 700;
color: var(--gray-900);
margin-bottom: 0.25rem;
}
.user-basic-info p {
color: var(--gray-500);
margin-bottom: 0.5rem;
}
.user-stats-quick {
display: flex;
gap: 2rem;
}
.stat-quick {
text-align: center;
}
.stat-number {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.75rem;
color: var(--gray-500);
text-transform: uppercase;
font-weight: 600;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.role-warning {
margin: 1.5rem 0;
padding: 1rem;
background: rgba(251, 191, 36, 0.1);
border: 1px solid #f59e0b;
border-radius: var(--radius-lg);
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
}
.role-warning.fade-in {
opacity: 1;
transform: translateY(0);
}
.warning-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #d97706;
margin-bottom: 0.75rem;
}
.warning-content {
color: #92400e;
font-size: 0.875rem;
}
.warning-content ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.warning-content li {
margin-bottom: 0.25rem;
}
.password-section {
margin: 2rem 0;
padding: 1.5rem;
background: var(--gray-50);
border-radius: var(--radius-lg);
border: 1px solid var(--gray-200);
}
.password-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 1.5rem;
}
.status-section {
margin: 2rem 0;
padding: 1.5rem;
background: var(--gray-50);
border-radius: var(--radius-lg);
border: 1px solid var(--gray-200);
}
.status-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 1.5rem;
}
.status-display {
display: grid;
gap: 1rem;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--white);
border-radius: var(--radius);
border: 1px solid var(--gray-200);
}
.status-item strong {
color: var(--gray-700);
font-weight: 500;
}
.status-item span {
color: var(--gray-900);
font-weight: 500;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.active {
background: rgba(5, 150, 105, 0.1);
color: var(--success-color);
}
.status-badge.inactive {
background: rgba(220, 38, 38, 0.1);
color: var(--danger-color);
}
.role-badge {
padding: 0.25rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.role-badge.admin {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
.role-badge.staff {
background: rgba(100, 116, 139, 0.1);
color: var(--gray-600);
}
.password-strength {
margin-top: 0.5rem;
font-size: 0.75rem;
padding: 0.5rem;
border-radius: var(--radius);
transition: var(--transition);
}
.password-strength.weak {
background-color: rgba(220, 38, 38, 0.1);
color: var(--danger-color);
}
.password-strength.medium {
background-color: rgba(217, 119, 6, 0.1);
color: var(--warning-color);
}
.password-strength.strong {
background-color: rgba(5, 150, 105, 0.1);
color: var(--success-color);
}
.password-match {
margin-top: 0.5rem;
font-size: 0.75rem;
padding: 0.5rem;
border-radius: var(--radius);
}
.password-match.match {
background-color: rgba(5, 150, 105, 0.1);
color: var(--success-color);
}
.password-match.no-match {
background-color: rgba(220, 38, 38, 0.1);
color: var(--danger-color);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 2px solid var(--gray-200);
border-radius: var(--radius-lg);
font-size: var(--font-size-base);
transition: var(--transition);
background-color: var(--white);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group input:disabled {
background-color: var(--gray-100);
color: var(--gray-500);
cursor: not-allowed;
}
.form-help {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--gray-500);
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--gray-200);
}
@media (max-width: 768px) {
.current-user-info {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.user-stats-quick {
justify-content: center;
gap: 1rem;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
align-items: stretch;
}
.status-item {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
}
</style>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
<!-- templates/errors/403.html -->
{% extends "base.html" %} {% block title %}Access Forbidden - QR Code
Management{% endblock %} {% block content %}
<div class="error-page">
<div class="error-container">
<div class="error-code">403</div>
<div class="error-message">
<h1>Access Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<p class="error-detail">This action requires administrator privileges.</p>
</div>
<div class="error-actions">
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i>
Go to Dashboard
</a>
{% if session.role == 'staff' %}
<a href="{{ url_for('profile') }}" class="btn btn-secondary">
<i class="fas fa-user"></i>
View Profile
</a>
{% endif %}
</div>
</div>
</div>
<style>
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: var(--spacing-8);
}
.error-container {
text-align: center;
max-width: 600px;
}
.error-code {
font-size: 8rem;
font-weight: 900;
color: var(--warning-color);
line-height: 1;
margin-bottom: var(--spacing-4);
background: linear-gradient(135deg, var(--warning-color), #b45309);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error-message h1 {
font-size: var(--font-size-3xl);
color: var(--gray-900);
margin-bottom: var(--spacing-4);
}
.error-message p {
font-size: var(--font-size-lg);
color: var(--gray-600);
margin-bottom: var(--spacing-4);
}
.error-detail {
font-size: var(--font-size-sm);
color: var(--warning-color);
font-weight: 600;
margin-bottom: var(--spacing-8) !important;
}
.error-actions {
display: flex;
gap: var(--spacing-4);
justify-content: center;
flex-wrap: wrap;
}
</style>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
<!-- templates/errors/404.html -->
{% extends "base.html" %} {% block title %}Page Not Found - QR Code Management{%
endblock %} {% block content %}
<div class="error-page">
<div class="error-container">
<div class="error-code">404</div>
<div class="error-message">
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
</div>
<div class="error-actions">
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i>
Go to Dashboard
</a>
<a href="javascript:history.back()" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
Go Back
</a>
</div>
</div>
</div>
<style>
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: var(--spacing-8);
}
.error-container {
text-align: center;
max-width: 600px;
}
.error-code {
font-size: 8rem;
font-weight: 900;
color: var(--primary-color);
line-height: 1;
margin-bottom: var(--spacing-4);
background: linear-gradient(
135deg,
var(--primary-color),
var(--primary-hover)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error-message h1 {
font-size: var(--font-size-3xl);
color: var(--gray-900);
margin-bottom: var(--spacing-4);
}
.error-message p {
font-size: var(--font-size-lg);
color: var(--gray-600);
margin-bottom: var(--spacing-8);
}
.error-actions {
display: flex;
gap: var(--spacing-4);
justify-content: center;
flex-wrap: wrap;
}
</style>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
<!-- templates/errors/500.html -->
{% extends "base.html" %} {% block title %}Server Error - QR Code Management{%
endblock %} {% block content %}
<div class="error-page">
<div class="error-container">
<div class="error-code">500</div>
<div class="error-message">
<h1>Internal Server Error</h1>
<p>Something went wrong on our end. We're working to fix it.</p>
<p class="error-detail">Please try again in a few moments.</p>
</div>
<div class="error-actions">
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i>
Go to Dashboard
</a>
<button onclick="location.reload()" class="btn btn-secondary">
<i class="fas fa-redo"></i>
Try Again
</button>
</div>
</div>
</div>
<style>
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: var(--spacing-8);
}
.error-container {
text-align: center;
max-width: 600px;
}
.error-code {
font-size: 8rem;
font-weight: 900;
color: var(--danger-color);
line-height: 1;
margin-bottom: var(--spacing-4);
background: linear-gradient(135deg, var(--danger-color), #b91c1c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error-message h1 {
font-size: var(--font-size-3xl);
color: var(--gray-900);
margin-bottom: var(--spacing-4);
}
.error-message p {
font-size: var(--font-size-lg);
color: var(--gray-600);
margin-bottom: var(--spacing-4);
}
.error-detail {
font-size: var(--font-size-sm);
color: var(--danger-color);
font-weight: 600;
margin-bottom: var(--spacing-8) !important;
}
.error-actions {
display: flex;
gap: var(--spacing-4);
justify-content: center;
flex-wrap: wrap;
}
</style>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %} {% block title %}Login - QR Code Management{% endblock
%} {% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<div class="auth-logo">
<i class="fas fa-qrcode"></i>
</div>
<h1>QR Manager</h1>
<p>Sign in to your account</p>
</div>
<form method="POST" class="auth-form">
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
Username
</label>
<input type="text" id="username" name="username" required />
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
Password
</label>
<input type="password" id="password" name="password" required />
</div>
<button type="submit" class="btn btn-primary btn-full">
<i class="fas fa-sign-in-alt"></i>
Sign In
</button>
</form>
<div class="auth-footer">
<p>
Don't have an account?
<a href="{{ url_for('register') }}" class="auth-link">Register here</a>
</p>
</div>
</div>
</div>
{% endblock %} {% block extra_scripts %}
<script>
// Add form validation and user experience improvements
document.addEventListener("DOMContentLoaded", function () {
const form = document.querySelector(".auth-form");
const inputs = form.querySelectorAll("input");
// Add focus effects
inputs.forEach((input) => {
input.addEventListener("focus", function () {
this.parentElement.classList.add("focused");
});
input.addEventListener("blur", function () {
if (!this.value) {
this.parentElement.classList.remove("focused");
}
});
});
// Form submission with loading state
form.addEventListener("submit", function () {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.innerHTML =
'<i class="fas fa-spinner fa-spin"></i> Signing In...';
submitBtn.disabled = true;
});
});
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Check In - {{ qr_code.name }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/qr_destination.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<meta name="robots" content="noindex, nofollow">
</head>
<body>
<div class="destination-container">
<!-- Header Section -->
<div class="destination-header">
<div class="header-icon">
<i class="fas fa-qrcode"></i>
</div>
<h1>{{ qr_code.location_event }}</h1>
<p class="location-info">
<i class="fas fa-map-marker-alt"></i>
{{ qr_code.location }}
</p>
</div>
<!-- QR Code Info Card -->
<div class="info-card">
<div class="info-header">
<h2>
<i class="fas fa-info-circle"></i>
Event Information
</h2>
</div>
<div class="info-details">
<div class="detail-item">
<div class="detail-icon">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="detail-content">
<strong>Event:</strong>
<span>{{ qr_code.location_event }}</span>
</div>
</div>
<div class="detail-item">
<div class="detail-icon">
<i class="fas fa-building"></i>
</div>
<div class="detail-content">
<strong>Location:</strong>
<span>{{ qr_code.location }}</span>
</div>
</div>
<div class="detail-item">
<div class="detail-icon">
<i class="fas fa-home"></i>
</div>
<div class="detail-content">
<strong>Address:</strong>
<span>{{ qr_code.location_address }}</span>
</div>
</div>
<div class="detail-item">
<div class="detail-icon">
<i class="fas fa-clock"></i>
</div>
<div class="detail-content">
<strong>Current Time:</strong>
<span id="currentTime">Loading...</span>
</div>
</div>
</div>
</div>
<!-- Check-in Form -->
<div class="checkin-card">
<div class="checkin-header">
<h2>
<i class="fas fa-user-check"></i>
Staff Check-In
</h2>
<p>Please enter your Employee ID to check in</p>
</div>
<form id="checkinForm" class="checkin-form">
<div class="form-group">
<label for="employee_id">
<i class="fas fa-id-badge"></i>
Employee ID
</label>
<input type="text"
id="employee_id"
name="employee_id"
required
placeholder="Enter your Employee ID"
autocomplete="off"
maxlength="20">
<small class="form-help">Use your official employee ID (3-20 characters)</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="btn-content">
<i class="fas fa-check"></i>
Check In Now
</span>
<div class="btn-loader" style="display: none;">
<i class="fas fa-spinner fa-spin"></i>
Processing...
</div>
</button>
</div>
</form>
<!-- Status Messages -->
<div id="statusMessage" class="status-message" style="display: none;">
<!-- Dynamic status messages will appear here -->
</div>
</div>
<!-- Success Card (Hidden by default) -->
<div id="successCard" class="success-card" style="display: none;">
<div class="success-icon">
<i class="fas fa-check-circle"></i>
</div>
<h2>Check-In Successful!</h2>
<div class="success-details">
<div class="success-item">
<strong>Employee ID:</strong>
<span id="successEmployeeId">-</span>
</div>
<div class="success-item">
<strong>Location:</strong>
<span id="successLocation">-</span>
</div>
<div class="success-item">
<strong>Event:</strong>
<span id="successEvent">-</span>
</div>
<div class="success-item">
<strong>Time:</strong>
<span id="successTime">-</span>
</div>
<div class="success-item">
<strong>Date:</strong>
<span id="successDate">-</span>
</div>
</div>
<div class="success-actions">
<button onclick="checkInAnother()" class="btn btn-secondary">
<i class="fas fa-plus"></i>
Check In Another Employee
</button>
</div>
</div>
<!-- Footer -->
<div class="destination-footer">
<p>
<i class="fas fa-shield-alt"></i>
Secure attendance tracking system
</p>
<small>© 2025 QR Management System</small>
</div>
</div>
<!-- Loading Overlay -->
<div id="loadingOverlay" class="loading-overlay" style="display: none;">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<p>Processing check-in...</p>
</div>
</div>
<script src="{{ url_for('static', filename='js/qr_destination.js') }}"></script>
<script>
// Initialize page with QR code URL
window.qrUrl = '{{ qr_code.qr_url }}';
window.locationName = '{{ qr_code.location }}';
window.eventName = '{{ qr_code.location_event }}';
</script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %} {% block title %}QR Code Not Found - QR Code
Management{% endblock %} {% block content %}
<div class="error-page">
<div class="error-container">
<div class="error-icon">
<i class="fas fa-qrcode"></i>
</div>
<div class="error-message">
<h1>QR Code Not Found</h1>
<p>
The QR code you're looking for doesn't exist or has been deactivated.
</p>
<p class="error-detail">
Please check with the person who provided this QR code.
</p>
</div>
<div class="error-actions">
{% if session.user_id %}
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i>
Go to Dashboard
</a>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i>
Login
</a>
{% endif %}
</div>
</div>
</div>
<style>
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: var(--spacing-8);
}
.error-container {
text-align: center;
max-width: 600px;
}
.error-icon {
font-size: 6rem;
color: var(--gray-400);
margin-bottom: var(--spacing-6);
}
.error-message h1 {
font-size: var(--font-size-3xl);
color: var(--gray-900);
margin-bottom: var(--spacing-4);
}
.error-message p {
font-size: var(--font-size-lg);
color: var(--gray-600);
margin-bottom: var(--spacing-4);
}
.error-detail {
font-size: var(--font-size-sm);
color: var(--gray-500);
font-style: italic;
margin-bottom: var(--spacing-8) !important;
}
.error-actions {
display: flex;
gap: var(--spacing-4);
justify-content: center;
flex-wrap: wrap;
}
</style>
{% endblock %}
+181
View File
@@ -0,0 +1,181 @@
{% extends "base.html" %} {% block title %}Register - QR Code Management{%
endblock %} {% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<div class="auth-logo">
<i class="fas fa-user-plus"></i>
</div>
<h1>Create Account</h1>
<p>Join our QR management platform</p>
</div>
<form method="POST" class="auth-form" id="registerForm">
<div class="form-group">
<label for="full_name">
<i class="fas fa-id-card"></i>
Full Name
</label>
<input type="text" id="full_name" name="full_name" required />
</div>
<div class="form-group">
<label for="email">
<i class="fas fa-envelope"></i>
Email Address
</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
Username
</label>
<input type="text" id="username" name="username" required />
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
Password
</label>
<input
type="password"
id="password"
name="password"
required
minlength="6"
/>
<div class="password-strength" id="passwordStrength"></div>
</div>
<div class="form-group">
<label for="confirm_password">
<i class="fas fa-lock"></i>
Confirm Password
</label>
<input
type="password"
id="confirm_password"
name="confirm_password"
required
/>
<div class="password-match" id="passwordMatch"></div>
</div>
<button type="submit" class="btn btn-primary btn-full">
<i class="fas fa-user-plus"></i>
Create Account
</button>
</form>
<div class="auth-footer">
<p>
Already have an account?
<a href="{{ url_for('login') }}" class="auth-link">Sign in here</a>
</p>
</div>
</div>
</div>
{% endblock %} {% block extra_scripts %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("registerForm");
const password = document.getElementById("password");
const confirmPassword = document.getElementById("confirm_password");
const passwordStrength = document.getElementById("passwordStrength");
const passwordMatch = document.getElementById("passwordMatch");
// Password strength checker
password.addEventListener("input", function () {
const value = this.value;
let strength = 0;
let feedback = [];
if (value.length >= 6) strength++;
else feedback.push("At least 6 characters");
if (/[A-Z]/.test(value)) strength++;
else feedback.push("One uppercase letter");
if (/[a-z]/.test(value)) strength++;
else feedback.push("One lowercase letter");
if (/[0-9]/.test(value)) strength++;
else feedback.push("One number");
if (/[^A-Za-z0-9]/.test(value)) strength++;
else feedback.push("One special character");
let strengthText = "";
let strengthClass = "";
if (strength < 2) {
strengthText = "Weak";
strengthClass = "weak";
} else if (strength < 4) {
strengthText = "Medium";
strengthClass = "medium";
} else {
strengthText = "Strong";
strengthClass = "strong";
}
passwordStrength.className = `password-strength ${strengthClass}`;
passwordStrength.textContent = `Strength: ${strengthText}`;
if (feedback.length > 0 && value.length > 0) {
passwordStrength.textContent += ` (Missing: ${feedback.join(", ")})`;
}
});
// Password match checker
function checkPasswordMatch() {
if (confirmPassword.value) {
if (password.value === confirmPassword.value) {
passwordMatch.textContent = "Passwords match";
passwordMatch.className = "password-match match";
} else {
passwordMatch.textContent = "Passwords do not match";
passwordMatch.className = "password-match no-match";
}
} else {
passwordMatch.textContent = "";
passwordMatch.className = "password-match";
}
}
password.addEventListener("input", checkPasswordMatch);
confirmPassword.addEventListener("input", checkPasswordMatch);
// Form submission validation
form.addEventListener("submit", function (e) {
if (password.value !== confirmPassword.value) {
e.preventDefault();
alert("Passwords do not match!");
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.innerHTML =
'<i class="fas fa-spinner fa-spin"></i> Creating Account...';
submitBtn.disabled = true;
});
// Add focus effects
const inputs = form.querySelectorAll("input");
inputs.forEach((input) => {
input.addEventListener("focus", function () {
this.parentElement.classList.add("focused");
});
input.addEventListener("blur", function () {
if (!this.value) {
this.parentElement.classList.remove("focused");
}
});
});
});
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+2102
View File
File diff suppressed because it is too large Load Diff
+493
View File
@@ -0,0 +1,493 @@
#!/usr/bin/env python3
"""
User Management Test Script
Test all user management functionalities to ensure they work correctly
"""
import requests
import json
from datetime import datetime
class UserManagementTester:
def __init__(self, base_url="http://localhost:5000"):
self.base_url = base_url
self.session = requests.Session()
self.admin_session_id = None
def login_as_admin(self, username="admin", password="admin123"):
"""Login as admin to test admin functions"""
print("🔐 Testing admin login...")
response = self.session.post(f"{self.base_url}/login", data={
'username': username,
'password': password
})
if response.status_code == 200 and "Welcome" in response.text:
print("✅ Admin login successful")
return True
else:
print("❌ Admin login failed")
return False
def test_create_user(self):
"""Test user creation functionality"""
print("\n👤 Testing user creation...")
test_user_data = {
'full_name': 'Test User',
'email': 'testuser@example.com',
'username': 'testuser',
'password': 'testpass123',
'role': 'staff'
}
response = self.session.post(f"{self.base_url}/users/create", data=test_user_data)
if response.status_code == 200:
if "created successfully" in response.text or response.url.endswith('/users'):
print("✅ User creation successful")
return True
print("❌ User creation failed")
print(f"Response status: {response.status_code}")
return False
def test_users_page_access(self):
"""Test access to users management page"""
print("\n📋 Testing users page access...")
response = self.session.get(f"{self.base_url}/users")
if response.status_code == 200 and "User Management" in response.text:
print("✅ Users page accessible")
return True
else:
print("❌ Users page access failed")
print(f"Response status: {response.status_code}")
return False
def get_test_user_id(self):
"""Get the ID of the test user for further testing"""
print("\n🔍 Finding test user ID...")
response = self.session.get(f"{self.base_url}/users")
if response.status_code == 200:
# Parse HTML to find user ID - this is a simple approach
# In a real test, you'd use BeautifulSoup or similar
content = response.text
if "testuser" in content:
print("✅ Test user found in users list")
# For demo purposes, we'll assume user ID 2 (after admin)
return 2
print("❌ Could not find test user")
return None
def test_user_promotion(self, user_id):
"""Test promoting a user to admin"""
print(f"\n⬆️ Testing user promotion (ID: {user_id})...")
response = self.session.get(f"{self.base_url}/users/{user_id}/promote")
if response.status_code == 200 or response.status_code == 302:
print("✅ User promotion request successful")
return True
else:
print("❌ User promotion failed")
print(f"Response status: {response.status_code}")
return False
def test_user_demotion(self, user_id):
"""Test demoting a user from admin to staff"""
print(f"\n⬇️ Testing user demotion (ID: {user_id})...")
response = self.session.get(f"{self.base_url}/users/{user_id}/demote")
if response.status_code == 200 or response.status_code == 302:
print("✅ User demotion request successful")
return True
else:
print("❌ User demotion failed")
print(f"Response status: {response.status_code}")
return False
def test_user_deactivation(self, user_id):
"""Test deactivating a user"""
print(f"\n🚫 Testing user deactivation (ID: {user_id})...")
response = self.session.get(f"{self.base_url}/users/{user_id}/delete")
if response.status_code == 200 or response.status_code == 302:
print("✅ User deactivation request successful")
return True
else:
print("❌ User deactivation failed")
print(f"Response status: {response.status_code}")
return False
def test_user_reactivation(self, user_id):
"""Test reactivating a user"""
print(f"\n✅ Testing user reactivation (ID: {user_id})...")
response = self.session.get(f"{self.base_url}/users/{user_id}/reactivate")
if response.status_code == 200 or response.status_code == 302:
print("✅ User reactivation request successful")
return True
else:
print("❌ User reactivation failed")
print(f"Response status: {response.status_code}")
return False
def test_user_edit(self, user_id):
"""Test editing user information"""
print(f"\n✏️ Testing user edit (ID: {user_id})...")
# First get the edit page
response = self.session.get(f"{self.base_url}/users/{user_id}/edit")
if response.status_code == 200:
print("✅ User edit page accessible")
# Test submitting updated user data
updated_data = {
'full_name': 'Updated Test User',
'email': 'updated_testuser@example.com',
'role': 'staff'
}
response = self.session.post(f"{self.base_url}/users/{user_id}/edit", data=updated_data)
if response.status_code == 200 or response.status_code == 302:
print("✅ User edit submission successful")
return True
print("❌ User edit failed")
print(f"Response status: {response.status_code}")
return False
def test_bulk_operations(self):
"""Test bulk user operations"""
print("\n📦 Testing bulk operations...")
# Test bulk deactivate API
test_data = {'user_ids': [2]} # Assuming test user has ID 2
response = self.session.post(
f"{self.base_url}/users/bulk/deactivate",
json=test_data,
headers={'Content-Type': 'application/json'}
)
if response.status_code == 200:
try:
result = response.json()
if result.get('success'):
print("✅ Bulk deactivate API works")
# Test bulk activate
response = self.session.post(
f"{self.base_url}/users/bulk/activate",
json=test_data,
headers={'Content-Type': 'application/json'}
)
if response.status_code == 200:
result = response.json()
if result.get('success'):
print("✅ Bulk activate API works")
return True
except json.JSONDecodeError:
pass
print("❌ Bulk operations failed")
return False
def test_admin_protections(self):
"""Test admin protection mechanisms"""
print("\n🛡️ Testing admin protection mechanisms...")
# Try to deactivate admin user (should fail)
response = self.session.get(f"{self.base_url}/users/1/delete") # Assuming admin is ID 1
# This should either redirect or show an error
if response.status_code in [200, 302]:
print("✅ Admin self-deactivation protection works")
return True
else:
print("❌ Admin protection test inconclusive")
return False
def run_all_tests(self):
"""Run comprehensive user management tests"""
print("🧪 Starting User Management Tests")
print("=" * 50)
results = []
# Test 1: Admin Login
results.append(("Admin Login", self.login_as_admin()))
if not results[-1][1]:
print("\n❌ Cannot proceed without admin login")
return False
# Test 2: Users Page Access
results.append(("Users Page Access", self.test_users_page_access()))
# Test 3: User Creation
results.append(("User Creation", self.test_create_user()))
# Get test user ID for further tests
test_user_id = self.get_test_user_id()
if test_user_id:
# Test 4: User Edit
results.append(("User Edit", self.test_user_edit(test_user_id)))
# Test 5: User Promotion
results.append(("User Promotion", self.test_user_promotion(test_user_id)))
# Test 6: User Demotion
results.append(("User Demotion", self.test_user_demotion(test_user_id)))
# Test 7: User Deactivation
results.append(("User Deactivation", self.test_user_deactivation(test_user_id)))
# Test 8: User Reactivation
results.append(("User Reactivation", self.test_user_reactivation(test_user_id)))
# Test 9: Bulk Operations
results.append(("Bulk Operations", self.test_bulk_operations()))
# Test 10: Admin Protections
results.append(("Admin Protections", self.test_admin_protections()))
# Print Results Summary
print("\n" + "=" * 50)
print("📊 TEST RESULTS SUMMARY")
print("=" * 50)
passed = 0
total = len(results)
for test_name, result in results:
status = "✅ PASS" if result else "❌ FAIL"
print(f"{test_name:<25} {status}")
if result:
passed += 1
print("-" * 50)
print(f"Total Tests: {total}")
print(f"Passed: {passed}")
print(f"Failed: {total - passed}")
print(f"Success Rate: {(passed/total)*100:.1f}%")
if passed == total:
print("\n🎉 ALL TESTS PASSED! User management is working correctly.")
elif passed >= total * 0.8:
print("\n⚠️ Most tests passed, but some issues need attention.")
else:
print("\n❌ Multiple tests failed. User management needs debugging.")
return passed == total
def manual_test_instructions():
"""Print manual testing instructions"""
print("\n" + "=" * 60)
print("📋 MANUAL TESTING INSTRUCTIONS")
print("=" * 60)
print("""
To manually test user management functionalities:
1. 🔐 Login as Admin:
- Go to /login
- Use: admin / admin123
- Verify you can access admin features
2. 👥 Access User Management:
- Go to /users
- Verify you see the user management page
- Check that statistics are displayed correctly
3. Create New User:
- Click "Add New User"
- Fill in: Name, Email, Username, Password, Role
- Submit and verify user appears in list
4. Edit User:
- Click dropdown next to any user (not yourself)
- Select "Edit User"
- Change name/email/role and save
- Verify changes appear in users list
5. Promote User:
- Find a staff user in the list
- Click dropdown "Promote to Admin"
- Confirm the action
- Verify user role changes to Admin
6. Demote User:
- Find an admin user (not yourself)
- Click dropdown "Demote to Staff"
- Confirm the action
- Verify user role changes to Staff
7. 🚫 Deactivate User:
- Find any user (not yourself)
- Click dropdown "Deactivate User"
- Confirm the action
- Verify user status changes to Inactive
8. Reactivate User:
- Find an inactive user
- Click dropdown "Reactivate User"
- Confirm the action
- Verify user status changes to Active
9. 📦 Bulk Operations:
- Click "Bulk Actions" button
- Select multiple users with checkboxes
- Try "Activate Selected" or "Deactivate Selected"
- Verify changes are applied to all selected users
10. 🛡 Admin Protection Tests:
- Try to deactivate yourself (should fail with error)
- Try to demote the last admin (should fail with error)
- Verify these protections work as expected
🔍 What to Look For:
- Flash messages appear for success/error states
- Page redirects work correctly after actions
- User list updates to reflect changes
- Protection mechanisms prevent dangerous actions
- Statistics update correctly after changes
- Search and filtering work properly
""")
def quick_setup_guide():
"""Print setup guide for user management"""
print("\n" + "=" * 60)
print("🚀 QUICK SETUP GUIDE")
print("=" * 60)
print("""
If user management functions are not working, check:
1. 📂 File Updates:
- Replace user management routes in app.py
- Update users.html template
- Ensure admin_required decorator is properly implemented
2. 🗄 Database Check:
- Verify users table exists
- Check that admin user exists with correct role
- Ensure foreign key relationships are set up
3. 🔧 Code Integration:
- Add the enhanced routes to your app.py
- Import required modules (datetime, etc.)
- Ensure session management is working
4. 🎨 Template Updates:
- Replace templates/users.html with enhanced version
- Verify CSS variables are defined in style.css
- Check JavaScript functions are loading
5. Configuration:
- Ensure Flask app has proper secret key
- Database connection is working
- Session configuration is correct
6. 🧪 Testing:
- Start with manual testing first
- Check browser console for JavaScript errors
- Verify network requests in browser dev tools
- Check Flask console for Python errors
Common Issues & Solutions:
- 404 errors: Routes not properly registered
- 500 errors: Database connection or Python syntax issues
- Permission denied: admin_required decorator not working
- JavaScript errors: Check for missing functions in templates
- CSS issues: Verify CSS variables are defined
""")
def run_database_check():
"""Check if database is properly set up for user management"""
print("\n🗄️ DATABASE SETUP CHECK")
print("=" * 40)
try:
import sqlite3
import os
# This is a basic check - adjust based on your database setup
print("✅ Database modules available")
# You could add actual database connectivity tests here
print("📝 To verify your database:")
print(" 1. Check that users table exists")
print(" 2. Verify admin user exists")
print(" 3. Test database connectivity")
print(" 4. Check foreign key constraints")
return True
except Exception as e:
print(f"❌ Database check failed: {e}")
return False
if __name__ == "__main__":
print("🧪 QR Code Management - User Management Tester")
print("=" * 60)
# Check if Flask app is running
tester = UserManagementTester()
try:
# Quick connectivity test
response = tester.session.get(f"{tester.base_url}/")
if response.status_code in [200, 302, 404]: # Any response means server is running
print("✅ Flask application appears to be running")
# Ask user what they want to do
print("\nSelect testing mode:")
print("1. 🤖 Run automated tests")
print("2. 📋 Show manual testing instructions")
print("3. 🚀 Show setup guide")
print("4. 🗄️ Check database setup")
choice = input("\nEnter choice (1-4): ").strip()
if choice == "1":
print("\n🤖 Running automated tests...")
success = tester.run_all_tests()
if not success:
print("\n💡 If tests failed, try the manual testing instructions.")
elif choice == "2":
manual_test_instructions()
elif choice == "3":
quick_setup_guide()
elif choice == "4":
run_database_check()
else:
print("Invalid choice. Showing manual instructions...")
manual_test_instructions()
else:
print("❌ Cannot connect to Flask application")
print("Make sure your app is running on http://localhost:5000")
except Exception as e:
print(f"❌ Connection failed: {e}")
print("\n🔧 Troubleshooting:")
print("1. Make sure Flask app is running: python app.py")
print("2. Check the URL is correct: http://localhost:5000")
print("3. Verify no firewall is blocking the connection")
print("\n📋 Showing manual testing instructions instead...")
manual_test_instructions()