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