Initial Codes
This commit is contained in:
+1459
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 %}
|
||||
@@ -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>© 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>
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
Reference in New Issue
Block a user