Merge pull request #5 from nguyen-ngo/08_01_2025
Revert "Enhance attendance report"
This commit is contained in:
@@ -183,7 +183,6 @@ def generate_qr_code(data):
|
|||||||
|
|
||||||
return img_str
|
return img_str
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter('strftime')
|
@app.template_filter('strftime')
|
||||||
def strftime_filter(value, format='%Y-%m-%d'):
|
def strftime_filter(value, format='%Y-%m-%d'):
|
||||||
"""Format datetime/date/string as strftime"""
|
"""Format datetime/date/string as strftime"""
|
||||||
@@ -627,68 +626,6 @@ def user_stats_api():
|
|||||||
print(f"Error fetching user stats: {e}")
|
print(f"Error fetching user stats: {e}")
|
||||||
return jsonify({'error': 'Failed to fetch user statistics'}), 500
|
return jsonify({'error': 'Failed to fetch user statistics'}), 500
|
||||||
|
|
||||||
# Optional: Add a separate endpoint to get location data as JSON for AJAX requests
|
|
||||||
@app.route('/api/attendance-location-data')
|
|
||||||
@admin_required
|
|
||||||
def attendance_location_data():
|
|
||||||
"""API endpoint to get attendance data with location information"""
|
|
||||||
try:
|
|
||||||
# Get query parameters
|
|
||||||
limit = request.args.get('limit', 100, type=int)
|
|
||||||
offset = request.args.get('offset', 0, type=int)
|
|
||||||
|
|
||||||
query_sql = """
|
|
||||||
SELECT
|
|
||||||
ad.employee_id,
|
|
||||||
ad.check_in_date,
|
|
||||||
ad.check_in_time,
|
|
||||||
ad.latitude,
|
|
||||||
ad.longitude,
|
|
||||||
ad.location_accuracy,
|
|
||||||
ad.address,
|
|
||||||
ad.location_source,
|
|
||||||
ad.location_name,
|
|
||||||
qr.name as qr_name
|
|
||||||
FROM attendance_data ad
|
|
||||||
JOIN qr_codes qr ON ad.qr_code_id = qr.id
|
|
||||||
WHERE ad.latitude IS NOT NULL AND ad.longitude IS NOT NULL
|
|
||||||
ORDER BY ad.created_timestamp DESC
|
|
||||||
LIMIT :limit OFFSET :offset
|
|
||||||
"""
|
|
||||||
|
|
||||||
query = db.session.execute(text(query_sql), {
|
|
||||||
'limit': limit,
|
|
||||||
'offset': offset
|
|
||||||
})
|
|
||||||
|
|
||||||
records = []
|
|
||||||
for row in query.fetchall():
|
|
||||||
records.append({
|
|
||||||
'employee_id': row.employee_id,
|
|
||||||
'check_in_date': row.check_in_date.strftime('%Y-%m-%d') if row.check_in_date else '',
|
|
||||||
'check_in_time': row.check_in_time.strftime('%H:%M') if row.check_in_time else '',
|
|
||||||
'latitude': row.latitude,
|
|
||||||
'longitude': row.longitude,
|
|
||||||
'accuracy': row.location_accuracy,
|
|
||||||
'address': row.address,
|
|
||||||
'location_source': row.location_source,
|
|
||||||
'location_name': row.location_name,
|
|
||||||
'qr_name': row.qr_name
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'records': records,
|
|
||||||
'count': len(records)
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting location data: {e}")
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
@app.route('/users/<int:user_id>/permanently-delete', methods=['GET', 'POST'])
|
@app.route('/users/<int:user_id>/permanently-delete', methods=['GET', 'POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def permanently_delete_user(user_id):
|
def permanently_delete_user(user_id):
|
||||||
@@ -1431,97 +1368,40 @@ def toggle_qr_status_api(qr_id):
|
|||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
@app.route('/attendance')
|
@app.route('/attendance')
|
||||||
@admin_required
|
#@admin_required
|
||||||
def attendance_report():
|
def attendance_report():
|
||||||
"""Enhanced attendance report page with location data"""
|
"""Attendance report page (Admin only)"""
|
||||||
try:
|
try:
|
||||||
# Get filter parameters
|
# Get filter parameters
|
||||||
date_filter = request.args.get('date', '')
|
date_filter = request.args.get('date', '')
|
||||||
location_filter = request.args.get('location', '')
|
location_filter = request.args.get('location', '')
|
||||||
employee_filter = request.args.get('employee', '')
|
employee_filter = request.args.get('employee', '')
|
||||||
|
|
||||||
# Enhanced query to include location data
|
# Base query using the view
|
||||||
query_sql = """
|
query = db.session.execute(text("SELECT * FROM attendance_report WHERE 1=1"))
|
||||||
SELECT
|
|
||||||
ad.id,
|
|
||||||
ad.employee_id,
|
|
||||||
ad.check_in_date,
|
|
||||||
ad.check_in_time,
|
|
||||||
ad.device_info,
|
|
||||||
ad.user_agent,
|
|
||||||
ad.ip_address,
|
|
||||||
ad.location_name,
|
|
||||||
ad.status,
|
|
||||||
ad.created_timestamp,
|
|
||||||
-- Location data columns
|
|
||||||
ad.latitude,
|
|
||||||
ad.longitude,
|
|
||||||
ad.location_accuracy,
|
|
||||||
ad.address,
|
|
||||||
ad.location_source,
|
|
||||||
ad.location_timestamp,
|
|
||||||
-- QR code info
|
|
||||||
qr.name as qr_name,
|
|
||||||
qr.location as qr_location,
|
|
||||||
qr.location_event,
|
|
||||||
qr.location_address as qr_address
|
|
||||||
FROM attendance_data ad
|
|
||||||
JOIN qr_codes qr ON ad.qr_code_id = qr.id
|
|
||||||
WHERE 1=1
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Apply filters to SQL query
|
# Apply filters (you can enhance this with proper SQLAlchemy filtering)
|
||||||
filter_params = {}
|
|
||||||
|
|
||||||
if date_filter:
|
|
||||||
query_sql += " AND ad.check_in_date = :date_filter"
|
|
||||||
filter_params['date_filter'] = date_filter
|
|
||||||
|
|
||||||
if location_filter:
|
|
||||||
query_sql += " AND ad.location_name ILIKE :location_filter"
|
|
||||||
filter_params['location_filter'] = f"%{location_filter}%"
|
|
||||||
|
|
||||||
if employee_filter:
|
|
||||||
query_sql += " AND ad.employee_id ILIKE :employee_filter"
|
|
||||||
filter_params['employee_filter'] = f"%{employee_filter}%"
|
|
||||||
|
|
||||||
# Order by latest first
|
|
||||||
query_sql += " ORDER BY ad.created_timestamp DESC"
|
|
||||||
|
|
||||||
# Execute query
|
|
||||||
query = db.session.execute(text(query_sql), filter_params)
|
|
||||||
attendance_records = query.fetchall()
|
attendance_records = query.fetchall()
|
||||||
|
|
||||||
# Get unique locations for filter dropdown
|
# Get unique locations for filter dropdown
|
||||||
locations_query = db.session.execute(text("""
|
locations_query = db.session.execute(text("""
|
||||||
SELECT DISTINCT location_name
|
SELECT DISTINCT location_name
|
||||||
FROM attendance_data
|
FROM attendance_data
|
||||||
WHERE location_name IS NOT NULL
|
|
||||||
ORDER BY location_name
|
ORDER BY location_name
|
||||||
"""))
|
"""))
|
||||||
locations = [row[0] for row in locations_query.fetchall()]
|
locations = [row[0] for row in locations_query.fetchall()]
|
||||||
|
|
||||||
# Enhanced statistics including location data
|
# Get attendance statistics
|
||||||
stats_query = db.session.execute(text("""
|
stats_query = db.session.execute(text("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_checkins,
|
COUNT(*) as total_checkins,
|
||||||
COUNT(DISTINCT employee_id) as unique_employees,
|
COUNT(DISTINCT employee_id) as unique_employees,
|
||||||
COUNT(DISTINCT qr_code_id) as active_locations,
|
COUNT(DISTINCT qr_code_id) as active_locations,
|
||||||
COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END) as today_checkins,
|
COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END) as today_checkins
|
||||||
-- Location statistics
|
|
||||||
COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END) as checkins_with_location,
|
|
||||||
COUNT(CASE WHEN location_accuracy <= 50 THEN 1 END) as high_accuracy_checkins,
|
|
||||||
COUNT(CASE WHEN location_accuracy > 50 AND location_accuracy <= 100 THEN 1 END) as medium_accuracy_checkins,
|
|
||||||
COUNT(CASE WHEN location_accuracy > 100 THEN 1 END) as low_accuracy_checkins
|
|
||||||
FROM attendance_data
|
FROM attendance_data
|
||||||
"""))
|
"""))
|
||||||
stats = stats_query.fetchone()
|
stats = stats_query.fetchone()
|
||||||
|
|
||||||
# Calculate location coverage percentage
|
|
||||||
location_coverage = 0
|
|
||||||
if stats.total_checkins > 0:
|
|
||||||
location_coverage = round((stats.checkins_with_location / stats.total_checkins) * 100, 1)
|
|
||||||
|
|
||||||
# Add today's date for template
|
# Add today's date for template
|
||||||
today_date = datetime.now().strftime('%Y-%m-%d')
|
today_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
current_date_formatted = datetime.now().strftime('%B %d')
|
current_date_formatted = datetime.now().strftime('%B %d')
|
||||||
@@ -1530,7 +1410,6 @@ def attendance_report():
|
|||||||
attendance_records=attendance_records,
|
attendance_records=attendance_records,
|
||||||
locations=locations,
|
locations=locations,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
location_coverage=location_coverage,
|
|
||||||
date_filter=date_filter,
|
date_filter=date_filter,
|
||||||
location_filter=location_filter,
|
location_filter=location_filter,
|
||||||
employee_filter=employee_filter,
|
employee_filter=employee_filter,
|
||||||
@@ -1539,8 +1418,6 @@ def attendance_report():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading attendance report: {e}")
|
print(f"Error loading attendance report: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
flash('Error loading attendance report.', 'error')
|
flash('Error loading attendance report.', 'error')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
@@ -1779,41 +1656,8 @@ def update_existing_qr_codes():
|
|||||||
print(f"Error updating existing QR codes: {e}")
|
print(f"Error updating existing QR codes: {e}")
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
def ensure_location_columns():
|
|
||||||
"""Ensure location columns exist in attendance_data table"""
|
|
||||||
try:
|
|
||||||
# Check if location columns exist
|
|
||||||
result = db.session.execute(text("""
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'attendance_data'
|
|
||||||
AND column_name IN ('latitude', 'longitude', 'location_accuracy',
|
|
||||||
'address', 'location_source', 'location_timestamp')
|
|
||||||
""")).fetchall()
|
|
||||||
|
|
||||||
existing_columns = [row[0] for row in result]
|
|
||||||
required_columns = ['latitude', 'longitude', 'location_accuracy', 'address', 'location_source', 'location_timestamp']
|
|
||||||
missing_columns = [col for col in required_columns if col not in existing_columns]
|
|
||||||
|
|
||||||
if missing_columns:
|
|
||||||
print(f"⚠️ Missing location columns: {', '.join(missing_columns)}")
|
|
||||||
print(" Run the location migration script first!")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
print(f"✅ All location columns exist")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ Could not check location columns: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
create_tables()
|
create_tables()
|
||||||
update_existing_qr_codes()
|
update_existing_qr_codes()
|
||||||
|
|
||||||
# Check location columns
|
|
||||||
if not ensure_location_columns():
|
|
||||||
print("\n🚨 Location columns missing! Run location migration first:")
|
|
||||||
print("python location_migration.py")
|
|
||||||
app.run(debug=True, host="0.0.0.0")
|
app.run(debug=True, host="0.0.0.0")
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Location Database Migration
|
|
||||||
Adds location columns to attendance_data table
|
|
||||||
"""
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Database connection
|
|
||||||
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://postgres:postgres1411@localhost/qr_management')
|
|
||||||
|
|
||||||
def add_location_columns():
|
|
||||||
"""Add location tracking columns to attendance_data table"""
|
|
||||||
|
|
||||||
print("🚀 Adding location columns to attendance_data table...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Connect to database
|
|
||||||
conn = psycopg2.connect(DATABASE_URL)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
print("✅ Connected to database")
|
|
||||||
|
|
||||||
# Check if attendance_data table exists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'attendance_data'
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
if not cursor.fetchone()[0]:
|
|
||||||
print("❌ attendance_data table not found!")
|
|
||||||
print(" Make sure your QR system is running first")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("✅ attendance_data table found")
|
|
||||||
|
|
||||||
# Add location columns one by one with error handling
|
|
||||||
location_columns = [
|
|
||||||
{
|
|
||||||
'name': 'latitude',
|
|
||||||
'sql': 'ALTER TABLE attendance_data ADD COLUMN latitude DOUBLE PRECISION',
|
|
||||||
'description': 'GPS latitude coordinate'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'longitude',
|
|
||||||
'sql': 'ALTER TABLE attendance_data ADD COLUMN longitude DOUBLE PRECISION',
|
|
||||||
'description': 'GPS longitude coordinate'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'location_accuracy',
|
|
||||||
'sql': 'ALTER TABLE attendance_data ADD COLUMN location_accuracy DOUBLE PRECISION',
|
|
||||||
'description': 'GPS accuracy in meters'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'address',
|
|
||||||
'sql': 'ALTER TABLE attendance_data ADD COLUMN address VARCHAR(500)',
|
|
||||||
'description': 'Resolved address from coordinates'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'location_source',
|
|
||||||
'sql': "ALTER TABLE attendance_data ADD COLUMN location_source VARCHAR(50) DEFAULT 'manual'",
|
|
||||||
'description': 'Source of location data (gps, network, manual)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'location_timestamp',
|
|
||||||
'sql': 'ALTER TABLE attendance_data ADD COLUMN location_timestamp TIMESTAMP',
|
|
||||||
'description': 'When location was captured'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
added_columns = []
|
|
||||||
skipped_columns = []
|
|
||||||
|
|
||||||
for column in location_columns:
|
|
||||||
try:
|
|
||||||
# Check if column already exists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.columns
|
|
||||||
WHERE table_name = 'attendance_data'
|
|
||||||
AND column_name = %s
|
|
||||||
);
|
|
||||||
""", (column['name'],))
|
|
||||||
|
|
||||||
if cursor.fetchone()[0]:
|
|
||||||
print(f" ⏭️ Column '{column['name']}' already exists")
|
|
||||||
skipped_columns.append(column['name'])
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add the column
|
|
||||||
cursor.execute(column['sql'])
|
|
||||||
added_columns.append(column['name'])
|
|
||||||
print(f" ✅ Added '{column['name']}' - {column['description']}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Error adding '{column['name']}': {e}")
|
|
||||||
|
|
||||||
# Commit all changes
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
print(f"\n📊 Migration Summary:")
|
|
||||||
print(f" ✅ Added columns: {len(added_columns)}")
|
|
||||||
print(f" ⏭️ Skipped columns: {len(skipped_columns)}")
|
|
||||||
|
|
||||||
if added_columns:
|
|
||||||
print(f" 📝 New columns: {', '.join(added_columns)}")
|
|
||||||
|
|
||||||
if skipped_columns:
|
|
||||||
print(f" 📝 Existing columns: {', '.join(skipped_columns)}")
|
|
||||||
|
|
||||||
# Verify the columns were added
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT column_name, data_type
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'attendance_data'
|
|
||||||
AND column_name IN ('latitude', 'longitude', 'location_accuracy',
|
|
||||||
'address', 'location_source', 'location_timestamp')
|
|
||||||
ORDER BY column_name;
|
|
||||||
""")
|
|
||||||
|
|
||||||
columns_info = cursor.fetchall()
|
|
||||||
|
|
||||||
print(f"\n📋 Location columns in database:")
|
|
||||||
for col_name, col_type in columns_info:
|
|
||||||
print(f" • {col_name}: {col_type}")
|
|
||||||
|
|
||||||
# Check existing data
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM attendance_data")
|
|
||||||
total_records = cursor.fetchone()[0]
|
|
||||||
print(f"\n📊 Total attendance records: {total_records}")
|
|
||||||
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print("\n🎉 Location columns added successfully!")
|
|
||||||
print("\nNext step: Update your app.py to save location data")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except psycopg2.Error as e:
|
|
||||||
print(f"❌ Database error: {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Unexpected error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def verify_columns():
|
|
||||||
"""Verify location columns exist and show sample data"""
|
|
||||||
try:
|
|
||||||
conn = psycopg2.connect(DATABASE_URL)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check columns exist
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT column_name, data_type, is_nullable
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'attendance_data'
|
|
||||||
AND column_name IN ('latitude', 'longitude', 'location_accuracy',
|
|
||||||
'address', 'location_source', 'location_timestamp')
|
|
||||||
ORDER BY column_name;
|
|
||||||
""")
|
|
||||||
|
|
||||||
columns = cursor.fetchall()
|
|
||||||
|
|
||||||
print(f"\n🔍 Verification Results:")
|
|
||||||
print(f" Found {len(columns)} location columns:")
|
|
||||||
|
|
||||||
for col_name, col_type, nullable in columns:
|
|
||||||
null_text = "NULL" if nullable == "YES" else "NOT NULL"
|
|
||||||
print(f" • {col_name}: {col_type} ({null_text})")
|
|
||||||
|
|
||||||
# Show sample with location data if any exists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT employee_id, latitude, longitude, location_accuracy,
|
|
||||||
address, location_source, created_timestamp
|
|
||||||
FROM attendance_data
|
|
||||||
WHERE latitude IS NOT NULL
|
|
||||||
ORDER BY created_timestamp DESC
|
|
||||||
LIMIT 3;
|
|
||||||
""")
|
|
||||||
|
|
||||||
location_records = cursor.fetchall()
|
|
||||||
|
|
||||||
if location_records:
|
|
||||||
print(f"\n📍 Sample records with location data:")
|
|
||||||
for record in location_records:
|
|
||||||
emp_id, lat, lng, acc, addr, source, timestamp = record
|
|
||||||
print(f" • {emp_id}: {lat:.6f}, {lng:.6f} (±{acc}m) - {source}")
|
|
||||||
if addr:
|
|
||||||
print(f" Address: {addr}")
|
|
||||||
else:
|
|
||||||
print(f"\n📍 No location data found yet (will be captured on next check-ins)")
|
|
||||||
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Verification failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def test_connection():
|
|
||||||
"""Test database connection"""
|
|
||||||
try:
|
|
||||||
conn = psycopg2.connect(DATABASE_URL)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT version();")
|
|
||||||
version = cursor.fetchone()[0]
|
|
||||||
print(f"✅ Database connection successful")
|
|
||||||
print(f" PostgreSQL version: {version.split()[1]}")
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Database connection failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("📍 QR Code System - Location Database Migration")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test connection first
|
|
||||||
if not test_connection():
|
|
||||||
print("\n❌ Cannot connect to database. Please check:")
|
|
||||||
print("1. PostgreSQL is running")
|
|
||||||
print("2. Database credentials are correct")
|
|
||||||
print("3. Database 'qr_management' exists")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Run migration
|
|
||||||
success = add_location_columns()
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# Verify columns were added
|
|
||||||
verify_columns()
|
|
||||||
|
|
||||||
print("\n✅ Database migration completed!")
|
|
||||||
print("\n📋 Next steps:")
|
|
||||||
print("1. Update your app.py with the location-saving code")
|
|
||||||
print("2. Restart your Flask application")
|
|
||||||
print("3. Test check-in with location data")
|
|
||||||
print("4. Check database for saved location records")
|
|
||||||
else:
|
|
||||||
print("\n❌ Migration failed!")
|
|
||||||
print("\nManual SQL commands (if needed):")
|
|
||||||
print("ALTER TABLE attendance_data ADD COLUMN latitude DOUBLE PRECISION;")
|
|
||||||
print("ALTER TABLE attendance_data ADD COLUMN longitude DOUBLE PRECISION;")
|
|
||||||
print("ALTER TABLE attendance_data ADD COLUMN location_accuracy DOUBLE PRECISION;")
|
|
||||||
print("ALTER TABLE attendance_data ADD COLUMN address VARCHAR(500);")
|
|
||||||
print("ALTER TABLE attendance_data ADD COLUMN location_source VARCHAR(50) DEFAULT 'manual';")
|
|
||||||
print("ALTER TABLE attendance_data ADD COLUMN location_timestamp TIMESTAMP;")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
+118
-371
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Attendance Report Page Styles
|
||||||
|
* static/css/attendance.css
|
||||||
|
*/
|
||||||
|
|
||||||
/* Page Container */
|
/* Page Container */
|
||||||
.attendance-page {
|
.attendance-page {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
@@ -104,43 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.warning::before {
|
.stat-card.warning::before {
|
||||||
background: linear-gradient(90deg, var(--warning-color), #d97706);
|
background: linear-gradient(90deg, var(--warning-color), #b45309);
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.danger::before {
|
|
||||||
background: linear-gradient(90deg, var(--danger-color), #b91c1c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* NEW: Enhanced Statistics Card for Location Data */
|
|
||||||
.stat-card.location-stats {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.location-stats::before {
|
|
||||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.location-stats .stat-icon {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.location-stats .stat-content h3 {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.location-stats .stat-content p {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.location-stats .stat-content small {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
@@ -160,24 +129,20 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.primary {
|
.stat-card.primary .stat-icon {
|
||||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.success {
|
.stat-card.success .stat-icon {
|
||||||
background: linear-gradient(135deg, var(--success-color), #047857);
|
background: linear-gradient(135deg, var(--success-color), #047857);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.info {
|
.stat-card.info .stat-icon {
|
||||||
background: linear-gradient(135deg, var(--info-color), #0369a1);
|
background: linear-gradient(135deg, var(--info-color), #0369a1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.warning {
|
.stat-card.warning .stat-icon {
|
||||||
background: linear-gradient(135deg, var(--warning-color), #d97706);
|
background: linear-gradient(135deg, var(--warning-color), #b45309);
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.danger {
|
|
||||||
background: linear-gradient(135deg, var(--danger-color), #b91c1c);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-content {
|
.stat-content {
|
||||||
@@ -189,13 +154,20 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
margin-bottom: var(--spacing-1);
|
margin-bottom: var(--spacing-1);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-content p {
|
.stat-content p {
|
||||||
|
color: var(--gray-600);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
margin-bottom: var(--spacing-1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-trend {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--gray-500);
|
color: var(--gray-500);
|
||||||
margin: 0;
|
font-style: italic;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filters Section */
|
/* Filters Section */
|
||||||
@@ -211,7 +183,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filters-header {
|
.filters-header {
|
||||||
padding: var(--spacing-6) var(--spacing-6) var(--spacing-4);
|
padding: var(--spacing-6);
|
||||||
border-bottom: 1px solid var(--gray-200);
|
border-bottom: 1px solid var(--gray-200);
|
||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
@@ -230,11 +202,14 @@
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filters-form {
|
||||||
|
padding: var(--spacing-6);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: var(--spacing-4);
|
gap: var(--spacing-4);
|
||||||
padding: var(--spacing-6);
|
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +220,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-group label {
|
.filter-group label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--gray-700);
|
color: var(--gray-700);
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-2);
|
gap: var(--spacing-2);
|
||||||
@@ -315,12 +290,6 @@
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-count {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-controls {
|
.table-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -385,46 +354,12 @@
|
|||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* NEW: Enhanced Table Styles for Location Columns */
|
|
||||||
.attendance-table th:nth-child(7),
|
|
||||||
.attendance-table th:nth-child(8),
|
|
||||||
.attendance-table th:nth-child(9),
|
|
||||||
.attendance-table th:nth-child(10) {
|
|
||||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
|
||||||
border-left: 2px solid #667eea;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-table th:nth-child(7)::before {
|
|
||||||
content: "📍";
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-table th[onclick*="sortTable(6)"] i,
|
|
||||||
.attendance-table th[onclick*="sortTable(7)"] i,
|
|
||||||
.attendance-table th[onclick*="sortTable(8)"] i,
|
|
||||||
.attendance-table th[onclick*="sortTable(9)"] i {
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-table td {
|
.attendance-table td {
|
||||||
padding: var(--spacing-4) var(--spacing-3);
|
padding: var(--spacing-4) var(--spacing-3);
|
||||||
border-bottom: 1px solid var(--gray-200);
|
border-bottom: 1px solid var(--gray-200);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attendance-table td:nth-child(7),
|
|
||||||
.attendance-table td:nth-child(8),
|
|
||||||
.attendance-table td:nth-child(9),
|
|
||||||
.attendance-table td:nth-child(10) {
|
|
||||||
background: rgba(102, 126, 234, 0.02);
|
|
||||||
border-left: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-table tbody tr {
|
.attendance-table tbody tr {
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
@@ -433,20 +368,6 @@
|
|||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.attendance-table tbody tr:hover .location-data,
|
|
||||||
.attendance-table tbody tr:hover .accuracy-badge,
|
|
||||||
.attendance-table tbody tr:hover .location-indicator {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-table tbody tr:focus,
|
|
||||||
.attendance-table tbody tr:focus-within {
|
|
||||||
background: rgba(102, 126, 234, 0.1) !important;
|
|
||||||
outline: 2px solid #667eea;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table Cell Content */
|
/* Table Cell Content */
|
||||||
.employee-info {
|
.employee-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -509,129 +430,6 @@
|
|||||||
color: var(--info-color);
|
color: var(--info-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* NEW: Location Data Specific Styles */
|
|
||||||
.location-data {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #495057;
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 2px 0;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coordinates {
|
|
||||||
display: block;
|
|
||||||
line-height: 1.3;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-top: 2px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-high {
|
|
||||||
background: #d4f4dd;
|
|
||||||
color: #166534;
|
|
||||||
border-color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-medium {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
border-color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-low {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
border-color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-indicator {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-gps {
|
|
||||||
background: #d4f4dd;
|
|
||||||
color: #166534;
|
|
||||||
border-color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-gps i {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-gps {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
border-color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-gps i {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-info {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6c757d;
|
|
||||||
max-width: 150px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-top: 2px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-info:hover {
|
|
||||||
white-space: normal;
|
|
||||||
max-width: none;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-link {
|
|
||||||
color: #2563eb;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 10px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
margin-top: 3px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Badge */
|
/* Status Badge */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -664,36 +462,20 @@
|
|||||||
/* Action Buttons */
|
/* Action Buttons */
|
||||||
.record-actions {
|
.record-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--spacing-1);
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: inline-flex;
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
font-size: var(--font-size-xs);
|
||||||
height: 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn[title*="View"] {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn[title*="View"]:hover {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-view {
|
.btn-view {
|
||||||
@@ -729,28 +511,28 @@
|
|||||||
/* Empty State */
|
/* Empty State */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: var(--spacing-20);
|
||||||
color: #6b7280;
|
color: var(--gray-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state i {
|
.empty-icon {
|
||||||
font-size: 4em;
|
font-size: 4rem;
|
||||||
margin-bottom: 20px;
|
color: var(--gray-300);
|
||||||
color: #d1d5db;
|
margin-bottom: var(--spacing-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state h3 {
|
.empty-state h3 {
|
||||||
font-size: 1.5em;
|
font-size: var(--font-size-xl);
|
||||||
margin-bottom: 10px;
|
color: var(--gray-700);
|
||||||
color: #374151;
|
margin-bottom: var(--spacing-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
margin-bottom: 20px;
|
font-size: var(--font-size-base);
|
||||||
|
margin-bottom: var(--spacing-6);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pagination */
|
/* Pagination */
|
||||||
@@ -852,63 +634,65 @@
|
|||||||
|
|
||||||
/* Modal Styles */
|
/* Modal Styles */
|
||||||
.modal {
|
.modal {
|
||||||
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
z-index: var(--z-modal);
|
||||||
align-items: center;
|
backdrop-filter: blur(4px);
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white;
|
position: absolute;
|
||||||
border-radius: 12px;
|
top: 50%;
|
||||||
max-width: 600px;
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
padding: 20px;
|
padding: var(--spacing-6);
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid var(--gray-200);
|
||||||
background: #f9fafb;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #111827;
|
font-size: var(--font-size-xl);
|
||||||
display: flex;
|
font-weight: 600;
|
||||||
align-items: center;
|
color: var(--gray-900);
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 18px;
|
font-size: var(--font-size-xl);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #6b7280;
|
color: var(--gray-500);
|
||||||
padding: 4px;
|
padding: var(--spacing-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
background: #e5e7eb;
|
background: var(--gray-200);
|
||||||
color: #374151;
|
color: var(--gray-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 20px;
|
padding: var(--spacing-6);
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -922,81 +706,7 @@
|
|||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-detail-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item strong {
|
|
||||||
color: #374151;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item span {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading States */
|
|
||||||
.loading-location {
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: loading 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loading {
|
|
||||||
0% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip Enhancements */
|
|
||||||
[title] {
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.attendance-table {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-data {
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-badge {
|
|
||||||
font-size: 8px;
|
|
||||||
padding: 1px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-info {
|
|
||||||
max-width: 100px;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.attendance-page {
|
.attendance-page {
|
||||||
padding: var(--spacing-4);
|
padding: var(--spacing-4);
|
||||||
@@ -1055,20 +765,57 @@
|
|||||||
.record-actions {
|
.record-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide coordinates and address columns on mobile */
|
@media (max-width: 480px) {
|
||||||
.attendance-table th:nth-child(8),
|
.attendance-table th,
|
||||||
.attendance-table td:nth-child(8),
|
.attendance-table td {
|
||||||
.attendance-table th:nth-child(10),
|
padding: var(--spacing-2);
|
||||||
.attendance-table td:nth-child(10) {
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-id {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-info {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content h3 {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.attendance-page {
|
||||||
|
background: white;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-header,
|
||||||
|
.filters-section,
|
||||||
|
.charts-section {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-data {
|
.attendance-table-section {
|
||||||
font-size: 8px;
|
box-shadow: none;
|
||||||
padding: 1px 3px;
|
border: 1px solid #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinates {
|
.action-btn {
|
||||||
display: none; /* Hide coordinates on mobile */
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+561
-263
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Enhanced Attendance Report JavaScript with Location Support
|
* Attendance Report JavaScript
|
||||||
* Fixed version - removes undefined function calls
|
* Handles filtering, sorting, pagination, and analytics for attendance data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Global variables
|
// Global variables
|
||||||
@@ -11,163 +11,144 @@ let sortDirection = 'asc';
|
|||||||
let attendanceData = [];
|
let attendanceData = [];
|
||||||
let filteredData = [];
|
let filteredData = [];
|
||||||
|
|
||||||
// Charts (if you want to add them later)
|
// Charts
|
||||||
let dailyChart = null;
|
let dailyChart = null;
|
||||||
let locationChart = null;
|
let locationChart = null;
|
||||||
|
|
||||||
// Initialize page when DOM is loaded
|
// Initialize page when DOM is loaded
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
console.log('Enhanced Attendance Report page initialized');
|
console.log('Attendance Report page initialized');
|
||||||
|
|
||||||
initializeReport();
|
initializeReport();
|
||||||
|
loadAttendanceData();
|
||||||
|
initializeCharts();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
|
||||||
// Initialize location-specific features
|
|
||||||
initializeLocationFeatures();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function initializeReport() {
|
function initializeReport() {
|
||||||
console.log('📊 Initializing attendance report...');
|
// Load data from table
|
||||||
|
|
||||||
// Load data from existing table
|
|
||||||
loadTableData();
|
loadTableData();
|
||||||
|
|
||||||
// Initialize pagination if needed
|
// Initialize pagination
|
||||||
updatePagination();
|
updatePagination();
|
||||||
|
|
||||||
// Apply any initial filters
|
// Apply initial filters if any
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
|
||||||
console.log('✅ Report initialized successfully');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTableData() {
|
function loadTableData() {
|
||||||
const table = document.getElementById('attendanceTable');
|
const table = document.getElementById('attendanceTable');
|
||||||
if (!table) {
|
if (table) {
|
||||||
console.log('⚠️ Attendance table not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = table.querySelectorAll('tbody tr');
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
attendanceData = Array.from(rows).map((row, index) => {
|
attendanceData = Array.from(rows).map((row, index) => {
|
||||||
const cells = row.querySelectorAll('td');
|
const cells = row.querySelectorAll('td');
|
||||||
return {
|
return {
|
||||||
id: row.dataset.recordId || index,
|
id: row.dataset.recordId,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
employeeId: cells[1] ? cells[1].textContent.trim() : '',
|
employeeId: cells[1] ? cells[1].textContent.trim() : '',
|
||||||
location: cells[2] ? cells[2].textContent.trim() : '',
|
location: cells[2] ? cells[2].textContent.trim() : '',
|
||||||
event: cells[3] ? cells[3].textContent.trim() : '',
|
event: cells[3] ? cells[3].textContent.trim() : '',
|
||||||
date: cells[4] ? cells[4].textContent.trim() : '',
|
date: cells[4] ? cells[4].textContent.trim() : '',
|
||||||
time: cells[5] ? cells[5].textContent.trim() : '',
|
time: cells[5] ? cells[5].textContent.trim() : '',
|
||||||
// Location data (new columns)
|
device: cells[6] ? cells[6].getAttribute('title') || cells[6].textContent.trim() : '',
|
||||||
gpsStatus: cells[6] ? cells[6].textContent.trim() : '',
|
status: cells[7] ? cells[7].textContent.trim() : '',
|
||||||
coordinates: cells[7] ? cells[7].textContent.trim() : '',
|
element: row
|
||||||
accuracy: cells[8] ? cells[8].textContent.trim() : '',
|
|
||||||
address: cells[9] ? cells[9].textContent.trim() : '',
|
|
||||||
device: cells[10] ? cells[10].getAttribute('title') || cells[10].textContent.trim() : '',
|
|
||||||
status: cells[11] ? cells[11].textContent.trim() : '',
|
|
||||||
row: row
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
filteredData = [...attendanceData];
|
filteredData = [...attendanceData];
|
||||||
console.log(`📋 Loaded ${attendanceData.length} attendance records`);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEventListeners() {
|
function setupEventListeners() {
|
||||||
// Entries per page selector
|
// Entries per page change
|
||||||
const entriesSelect = document.getElementById('entriesPerPage');
|
const entriesSelect = document.getElementById('entriesPerPage');
|
||||||
if (entriesSelect) {
|
if (entriesSelect) {
|
||||||
entriesSelect.addEventListener('change', changeEntriesPerPage);
|
entriesSelect.addEventListener('change', changeEntriesPerPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search functionality (if you have a search input)
|
// Filter form
|
||||||
const searchInput = document.getElementById('searchInput');
|
const filtersForm = document.getElementById('filtersForm');
|
||||||
if (searchInput) {
|
if (filtersForm) {
|
||||||
searchInput.addEventListener('input', handleSearch);
|
filtersForm.addEventListener('submit', function(e) {
|
||||||
}
|
|
||||||
|
|
||||||
// Filter form submission
|
|
||||||
const filterForm = document.querySelector('form[action*="attendance"]');
|
|
||||||
if (filterForm) {
|
|
||||||
filterForm.addEventListener('submit', handleFilterSubmit);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('👂 Event listeners set up');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeLocationFeatures() {
|
|
||||||
console.log('📍 Initializing location features...');
|
|
||||||
|
|
||||||
// Count records with location data
|
|
||||||
const recordsWithLocation = attendanceData.filter(record =>
|
|
||||||
record.gpsStatus && record.gpsStatus.includes('GPS')
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const locationCoverage = attendanceData.length > 0
|
|
||||||
? Math.round((recordsWithLocation / attendanceData.length) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
console.log(`📊 Location coverage: ${recordsWithLocation}/${attendanceData.length} (${locationCoverage}%)`);
|
|
||||||
|
|
||||||
// Add click handlers for map links
|
|
||||||
document.querySelectorAll('.map-link').forEach(link => {
|
|
||||||
link.addEventListener('click', function(e) {
|
|
||||||
console.log('🗺️ Opening map link:', this.href);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add click handlers for view record buttons
|
|
||||||
document.querySelectorAll('.action-btn[title*="View"]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const recordId = this.closest('tr').dataset.recordId;
|
applyFilters();
|
||||||
if (recordId) {
|
});
|
||||||
viewRecord(recordId);
|
}
|
||||||
|
|
||||||
|
// 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() {
|
function changeEntriesPerPage() {
|
||||||
const select = document.getElementById('entriesPerPage');
|
const select = document.getElementById('entriesPerPage');
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
entriesPerPage = select.value === 'all' ? filteredData.length : parseInt(select.value);
|
entriesPerPage = select.value === 'all' ? filteredData.length : parseInt(select.value);
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
|
updateTable();
|
||||||
console.log(`📄 Changed entries per page to: ${entriesPerPage}`);
|
|
||||||
displayPage();
|
|
||||||
updatePagination();
|
updatePagination();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch(event) {
|
function applyFilters() {
|
||||||
const searchTerm = event.target.value.toLowerCase().trim();
|
const dateFilter = document.getElementById('date')?.value || '';
|
||||||
|
const locationFilter = document.getElementById('location')?.value || '';
|
||||||
|
const employeeFilter = document.getElementById('employee')?.value.toLowerCase() || '';
|
||||||
|
|
||||||
if (searchTerm === '') {
|
filteredData = attendanceData.filter(record => {
|
||||||
filteredData = [...attendanceData];
|
const matchesDate = !dateFilter || record.date === dateFilter;
|
||||||
} else {
|
const matchesLocation = !locationFilter || record.location === locationFilter;
|
||||||
filteredData = attendanceData.filter(record =>
|
const matchesEmployee = !employeeFilter ||
|
||||||
record.employeeId.toLowerCase().includes(searchTerm) ||
|
record.employeeId.toLowerCase().includes(employeeFilter);
|
||||||
record.location.toLowerCase().includes(searchTerm) ||
|
|
||||||
record.event.toLowerCase().includes(searchTerm) ||
|
return matchesDate && matchesLocation && matchesEmployee;
|
||||||
record.address.toLowerCase().includes(searchTerm)
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
displayPage();
|
updateTable();
|
||||||
updatePagination();
|
updatePagination();
|
||||||
|
updateFilterStats();
|
||||||
console.log(`🔍 Search results: ${filteredData.length} records found`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFilterSubmit(event) {
|
function clearFilters() {
|
||||||
// Let the form submit normally to reload with filters
|
// Clear form inputs
|
||||||
console.log('🔽 Applying filters...');
|
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) {
|
function sortTable(columnIndex) {
|
||||||
|
const headers = ['index', 'employeeId', 'location', 'event', 'date', 'time', 'device', 'status'];
|
||||||
|
const column = headers[columnIndex];
|
||||||
|
|
||||||
if (sortColumn === columnIndex) {
|
if (sortColumn === columnIndex) {
|
||||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
@@ -175,251 +156,568 @@ function sortTable(columnIndex) {
|
|||||||
sortDirection = 'asc';
|
sortDirection = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sort indicators
|
|
||||||
updateSortIndicators();
|
|
||||||
|
|
||||||
// Sort the data
|
|
||||||
const sortKey = getSortKey(columnIndex);
|
|
||||||
if (sortKey) {
|
|
||||||
filteredData.sort((a, b) => {
|
filteredData.sort((a, b) => {
|
||||||
let aVal = a[sortKey] || '';
|
let aVal = a[column];
|
||||||
let bVal = b[sortKey] || '';
|
let bVal = b[column];
|
||||||
|
|
||||||
// Convert to strings for comparison
|
// 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();
|
aVal = aVal.toString().toLowerCase();
|
||||||
bVal = bVal.toString().toLowerCase();
|
bVal = bVal.toString().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
if (sortDirection === 'asc') {
|
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
||||||
return aVal.localeCompare(bVal);
|
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 {
|
} else {
|
||||||
return bVal.localeCompare(aVal);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
displayPage();
|
|
||||||
|
|
||||||
console.log(`🔄 Sorted by column ${columnIndex} (${sortDirection})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSortKey(columnIndex) {
|
|
||||||
const sortKeys = {
|
|
||||||
0: 'index',
|
|
||||||
1: 'employeeId',
|
|
||||||
2: 'location',
|
|
||||||
3: 'event',
|
|
||||||
4: 'date',
|
|
||||||
5: 'time',
|
|
||||||
6: 'gpsStatus',
|
|
||||||
7: 'coordinates',
|
|
||||||
8: 'accuracy',
|
|
||||||
9: 'address',
|
|
||||||
10: 'device',
|
|
||||||
11: 'status'
|
|
||||||
};
|
|
||||||
|
|
||||||
return sortKeys[columnIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSortIndicators() {
|
|
||||||
// Update sort arrows in table headers
|
|
||||||
document.querySelectorAll('.attendance-table th i.fas').forEach((icon, index) => {
|
|
||||||
icon.className = 'fas fa-sort';
|
icon.className = 'fas fa-sort';
|
||||||
|
}
|
||||||
if (index === sortColumn) {
|
|
||||||
icon.className = sortDirection === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayPage() {
|
function updateTable() {
|
||||||
const table = document.getElementById('attendanceTable');
|
const tbody = document.querySelector('#attendanceTable tbody');
|
||||||
if (!table) return;
|
|
||||||
|
|
||||||
const tbody = table.querySelector('tbody');
|
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
// Hide all rows first
|
|
||||||
tbody.querySelectorAll('tr').forEach(row => {
|
|
||||||
row.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
const startIndex = (currentPage - 1) * entriesPerPage;
|
const startIndex = (currentPage - 1) * entriesPerPage;
|
||||||
const endIndex = startIndex + (entriesPerPage === filteredData.length ? filteredData.length : entriesPerPage);
|
const endIndex = entriesPerPage === filteredData.length ?
|
||||||
|
filteredData.length :
|
||||||
|
Math.min(startIndex + entriesPerPage, filteredData.length);
|
||||||
|
|
||||||
// Show relevant rows
|
// Hide all rows first
|
||||||
for (let i = startIndex; i < endIndex && i < filteredData.length; i++) {
|
attendanceData.forEach(record => {
|
||||||
const record = filteredData[i];
|
if (record.element) {
|
||||||
if (record.row) {
|
record.element.style.display = 'none';
|
||||||
record.row.style.display = '';
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`📄 Displaying records ${startIndex + 1}-${Math.min(endIndex, filteredData.length)} of ${filteredData.length}`);
|
// 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() {
|
function updatePagination() {
|
||||||
// This is a placeholder for pagination controls
|
const container = document.getElementById('paginationContainer');
|
||||||
// You can implement pagination UI here if needed
|
if (!container) return;
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredData.length / entriesPerPage);
|
const totalPages = Math.ceil(filteredData.length / entriesPerPage);
|
||||||
console.log(`📄 Page ${currentPage} of ${totalPages}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
if (totalPages <= 1) {
|
||||||
// Filters are handled by the backend through form submission
|
container.innerHTML = '';
|
||||||
// This function can be used for client-side filtering if needed
|
|
||||||
console.log('🔽 Filters applied');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export and utility functions
|
|
||||||
function exportToCSV() {
|
|
||||||
const table = document.getElementById('attendanceTable');
|
|
||||||
if (!table) {
|
|
||||||
console.error('❌ Table not found for export');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let csv = [];
|
let paginationHTML = '<div class="pagination">';
|
||||||
const rows = table.querySelectorAll('tr');
|
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
// Previous button
|
||||||
const row = [];
|
paginationHTML += `
|
||||||
const cols = rows[i].querySelectorAll('td, th');
|
<button onclick="goToPage(${currentPage - 1})"
|
||||||
|
class="pagination-btn"
|
||||||
|
${currentPage === 1 ? 'disabled' : ''}>
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
for (let j = 0; j < cols.length - 1; j++) { // Skip actions column
|
// Page numbers
|
||||||
let cellText = cols[j].innerText.replace(/"/g, '""');
|
const maxVisiblePages = 5;
|
||||||
// Clean up the text (remove extra whitespace, newlines)
|
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
||||||
cellText = cellText.replace(/\s+/g, ' ').trim();
|
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||||
row.push('"' + cellText + '"');
|
|
||||||
}
|
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||||
csv.push(row.join(','));
|
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download CSV
|
if (startPage > 1) {
|
||||||
const csvFile = new Blob([csv.join('\n')], { type: 'text/csv' });
|
paginationHTML += `<button onclick="goToPage(1)" class="pagination-btn">1</button>`;
|
||||||
const downloadLink = document.createElement('a');
|
if (startPage > 2) {
|
||||||
downloadLink.download = `attendance_report_${new Date().toISOString().slice(0, 10)}.csv`;
|
paginationHTML += '<span class="pagination-ellipsis">...</span>';
|
||||||
downloadLink.href = window.URL.createObjectURL(csvFile);
|
}
|
||||||
downloadLink.style.display = 'none';
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
|
|
||||||
console.log('📄 CSV exported successfully');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printReport() {
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
window.print();
|
paginationHTML += `
|
||||||
console.log('🖨️ Print dialog opened');
|
<button onclick="goToPage(${i})"
|
||||||
|
class="pagination-btn ${i === currentPage ? 'active' : ''}">
|
||||||
|
${i}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewRecord(recordId) {
|
if (endPage < totalPages) {
|
||||||
console.log('👁️ Viewing record:', recordId);
|
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 modal = document.getElementById('recordModal');
|
||||||
const detailsDiv = document.getElementById('recordDetails');
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalBody = document.getElementById('modalBody');
|
||||||
|
|
||||||
if (!modal || !detailsDiv) {
|
if (!modal || !modalTitle || !modalBody) return;
|
||||||
console.error('❌ Modal elements not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the record row
|
modalTitle.textContent = `Attendance Record - ${record.employeeId}`;
|
||||||
const row = document.querySelector(`tr[data-record-id="${recordId}"]`);
|
|
||||||
if (!row) {
|
|
||||||
console.error('❌ Record row not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cells = row.querySelectorAll('td');
|
modalBody.innerHTML = `
|
||||||
|
<div class="record-details">
|
||||||
detailsDiv.innerHTML = `
|
<div class="detail-grid">
|
||||||
<div class="record-detail-grid">
|
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<strong>Employee ID:</strong>
|
<strong>Employee ID:</strong>
|
||||||
<span>${cells[1] ? cells[1].textContent.trim() : 'N/A'}</span>
|
<span>${record.employeeId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<strong>Location:</strong>
|
<strong>Location:</strong>
|
||||||
<span>${cells[2] ? cells[2].textContent.trim() : 'N/A'}</span>
|
<span>${record.location}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<strong>Event:</strong>
|
<strong>Event:</strong>
|
||||||
<span>${cells[3] ? cells[3].textContent.trim() : 'N/A'}</span>
|
<span>${record.event}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<strong>Date:</strong>
|
<strong>Date:</strong>
|
||||||
<span>${cells[4] ? cells[4].textContent.trim() : 'N/A'}</span>
|
<span>${record.date}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<strong>Time:</strong>
|
<strong>Time:</strong>
|
||||||
<span>${cells[5] ? cells[5].textContent.trim() : 'N/A'}</span>
|
<span>${record.time}</span>
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>GPS Status:</strong>
|
|
||||||
<span>${cells[6] ? cells[6].textContent.trim() : 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Coordinates:</strong>
|
|
||||||
<span>${cells[7] ? cells[7].textContent.trim() : 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Accuracy:</strong>
|
|
||||||
<span>${cells[8] ? cells[8].textContent.trim() : 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Address:</strong>
|
|
||||||
<span>${cells[9] ? cells[9].textContent.trim() : 'N/A'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<strong>Device:</strong>
|
<strong>Device:</strong>
|
||||||
<span>${cells[10] ? cells[10].textContent.trim() : 'N/A'}</span>
|
<span>${record.device}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<strong>Status:</strong>
|
<strong>Status:</strong>
|
||||||
<span>${cells[11] ? cells[11].textContent.trim() : 'N/A'}</span>
|
<span class="status-badge ${record.status.toLowerCase()}">
|
||||||
|
${record.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
console.log('✅ Record modal opened');
|
setTimeout(() => modal.classList.add('show'), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
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');
|
const modal = document.getElementById('recordModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
console.log('❌ Modal closed');
|
}, 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
// Export functionality
|
||||||
// Get the current URL without query parameters
|
function exportAttendance() {
|
||||||
const baseUrl = window.location.origin + window.location.pathname;
|
const exportData = filteredData.map(record => ({
|
||||||
window.location.href = baseUrl;
|
'Employee ID': record.employeeId,
|
||||||
|
'Location': record.location,
|
||||||
|
'Event': record.event,
|
||||||
|
'Date': record.date,
|
||||||
|
'Time': record.time,
|
||||||
|
'Device': record.device,
|
||||||
|
'Status': record.status
|
||||||
|
}));
|
||||||
|
|
||||||
console.log('🔄 Clearing filters and reloading');
|
const csv = convertToCSV(exportData);
|
||||||
|
downloadCSV(csv, `attendance_report_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
|
||||||
|
showToast('Attendance data exported successfully', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global event handlers
|
function convertToCSV(data) {
|
||||||
window.onclick = function(event) {
|
if (!data.length) return '';
|
||||||
const modal = document.getElementById('recordModal');
|
|
||||||
if (event.target === modal) {
|
const headers = Object.keys(data[0]);
|
||||||
closeModal();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make functions globally available
|
function refreshReport() {
|
||||||
window.exportToCSV = exportToCSV;
|
showToast('Refreshing report...', 'info');
|
||||||
window.printReport = printReport;
|
setTimeout(() => {
|
||||||
window.viewRecord = viewRecord;
|
window.location.reload();
|
||||||
window.closeModal = closeModal;
|
}, 500);
|
||||||
window.clearFilters = clearFilters;
|
}
|
||||||
|
|
||||||
|
// 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.sortTable = sortTable;
|
||||||
window.changeEntriesPerPage = changeEntriesPerPage;
|
window.changeEntriesPerPage = changeEntriesPerPage;
|
||||||
|
window.clearFilters = clearFilters;
|
||||||
console.log('📍 Enhanced Attendance Report JavaScript loaded successfully');
|
window.goToPage = goToPage;
|
||||||
console.log('🔧 Available functions: exportToCSV, printReport, viewRecord, sortTable');
|
window.viewRecordDetails = viewRecordDetails;
|
||||||
|
window.editRecord = editRecord;
|
||||||
|
window.deleteRecord = deleteRecord;
|
||||||
|
window.closeRecordModal = closeRecordModal;
|
||||||
|
window.exportAttendance = exportAttendance;
|
||||||
|
window.refreshReport = refreshReport;
|
||||||
+580
-376
File diff suppressed because it is too large
Load Diff
+107
-406
@@ -1,122 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Attendance Report{% endblock %}
|
{% block title %}Attendance Report - QR Code Management{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_head %}
|
||||||
|
<!-- Attendance-specific CSS -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/attendance.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/attendance.css') }}">
|
||||||
<style>
|
<!-- Chart.js for analytics -->
|
||||||
/* Additional styles for location data */
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
||||||
.location-data {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #495057;
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coordinates {
|
|
||||||
display: block;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-high {
|
|
||||||
background: #d4f4dd;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-medium {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accuracy-low {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-indicator {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-gps {
|
|
||||||
background: #d4f4dd;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-gps {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-info {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6c757d;
|
|
||||||
max-width: 150px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-link {
|
|
||||||
color: #2563eb;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 10px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-stats {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-stats .stat-icon {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsiveness for location columns */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.location-data {
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coordinates {
|
|
||||||
display: none; /* Hide coordinates on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-info {
|
|
||||||
max-width: 80px;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -128,66 +18,65 @@
|
|||||||
<i class="fas fa-chart-line"></i>
|
<i class="fas fa-chart-line"></i>
|
||||||
Attendance Report
|
Attendance Report
|
||||||
</h1>
|
</h1>
|
||||||
<p>Comprehensive attendance tracking with location data for {{ current_date_formatted }}</p>
|
<p>Monitor and analyze staff attendance across all locations</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button onclick="exportToCSV()" class="btn btn-outline">
|
<button onclick="exportAttendance()" class="btn btn-success">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
Export CSV
|
Export Data
|
||||||
</button>
|
</button>
|
||||||
<button onclick="printReport()" class="btn btn-outline">
|
<button onclick="refreshReport()" class="btn btn-secondary">
|
||||||
<i class="fas fa-print"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
Print Report
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-arrow-left"></i>
|
|
||||||
Back to Dashboard
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Section -->
|
<!-- Statistics Cards -->
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card primary">
|
<div class="stat-card primary">
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-user-check"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<h3>{{ stats.total_checkins }}</h3>
|
<h3>{{ stats.total_checkins or 0 }}</h3>
|
||||||
<p>Total Check-ins</p>
|
<p>Total Check-ins</p>
|
||||||
|
<span class="stat-trend">All time</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card success">
|
<div class="stat-card success">
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<i class="fas fa-user-check"></i>
|
<i class="fas fa-users"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<h3>{{ stats.unique_employees }}</h3>
|
<h3>{{ stats.unique_employees or 0 }}</h3>
|
||||||
<p>Unique Employees</p>
|
<p>Unique Employees</p>
|
||||||
|
<span class="stat-trend">Have checked in</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card info">
|
<div class="stat-card info">
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-calendar-day"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<h3>{{ stats.today_checkins }}</h3>
|
|
||||||
<p>Today's Check-ins</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NEW: Location Statistics Card -->
|
|
||||||
<div class="stat-card location-stats">
|
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<i class="fas fa-map-marker-alt"></i>
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<h3>{{ stats.checkins_with_location }}</h3>
|
<h3>{{ stats.active_locations or 0 }}</h3>
|
||||||
<p>With GPS Location</p>
|
<p>Active Locations</p>
|
||||||
<small>{{ location_coverage }}% coverage</small>
|
<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">{{ current_date_formatted or 'Today' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,7 +92,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="GET" action="{{ url_for('attendance_report') }}">
|
<form method="GET" class="filters-form" id="filtersForm">
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="date">
|
<label for="date">
|
||||||
@@ -214,19 +103,19 @@
|
|||||||
id="date"
|
id="date"
|
||||||
name="date"
|
name="date"
|
||||||
value="{{ date_filter }}"
|
value="{{ date_filter }}"
|
||||||
max="{{ today_date }}">
|
max="{{ today_date or '' }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="location">
|
<label for="location">
|
||||||
<i class="fas fa-map-marker-alt"></i>
|
<i class="fas fa-building"></i>
|
||||||
Location
|
Location
|
||||||
</label>
|
</label>
|
||||||
<select id="location" name="location">
|
<select id="location" name="location">
|
||||||
<option value="">All Locations</option>
|
<option value="">All Locations</option>
|
||||||
{% for location in locations %}
|
{% for location in locations %}
|
||||||
<option value="{{ location }}"
|
<option value="{{ location }}"
|
||||||
{% if location == location_filter %}selected{% endif %}>
|
{{ 'selected' if location == location_filter else '' }}>
|
||||||
{{ location }}
|
{{ location }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -266,9 +155,6 @@
|
|||||||
<h3>
|
<h3>
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
Attendance Records
|
Attendance Records
|
||||||
{% if attendance_records %}
|
|
||||||
<span class="record-count">({{ attendance_records|length }} records)</span>
|
|
||||||
{% endif %}
|
|
||||||
</h3>
|
</h3>
|
||||||
<div class="table-controls">
|
<div class="table-controls">
|
||||||
<div class="entries-per-page">
|
<div class="entries-per-page">
|
||||||
@@ -295,13 +181,8 @@
|
|||||||
<th onclick="sortTable(3)">Event <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(4)">Date <i class="fas fa-sort"></i></th>
|
||||||
<th onclick="sortTable(5)">Time <i class="fas fa-sort"></i></th>
|
<th onclick="sortTable(5)">Time <i class="fas fa-sort"></i></th>
|
||||||
<!-- NEW: Location Data Columns -->
|
<th onclick="sortTable(6)">Device <i class="fas fa-sort"></i></th>
|
||||||
<th onclick="sortTable(6)">GPS Status <i class="fas fa-sort"></i></th>
|
<th onclick="sortTable(7)">Status <i class="fas fa-sort"></i></th>
|
||||||
<th onclick="sortTable(7)">Coordinates <i class="fas fa-sort"></i></th>
|
|
||||||
<th onclick="sortTable(8)">Accuracy <i class="fas fa-sort"></i></th>
|
|
||||||
<th onclick="sortTable(9)">Address <i class="fas fa-sort"></i></th>
|
|
||||||
<th onclick="sortTable(10)">Device <i class="fas fa-sort"></i></th>
|
|
||||||
<th onclick="sortTable(11)">Status <i class="fas fa-sort"></i></th>
|
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -335,86 +216,37 @@
|
|||||||
{{ record.check_in_time.strftime('%H:%M') }}
|
{{ record.check_in_time.strftime('%H:%M') }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- NEW: GPS Status Column -->
|
|
||||||
<td>
|
<td>
|
||||||
{% if record.latitude and record.longitude %}
|
<div class="device-info">
|
||||||
<span class="location-indicator has-gps">
|
<i class="fas fa-mobile-alt"></i>
|
||||||
<i class="fas fa-satellite"></i>
|
<span title="{{ record.device_info }}">
|
||||||
GPS
|
{{ record.device_info[:20] }}{% if record.device_info|length > 20 %}...{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
|
||||||
<span class="location-indicator no-gps">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
No GPS
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- NEW: Coordinates Column -->
|
|
||||||
<td>
|
|
||||||
{% if record.latitude and record.longitude %}
|
|
||||||
<div class="location-data">
|
|
||||||
<span class="coordinates">
|
|
||||||
{{ "%.6f"|format(record.latitude) }}<br>
|
|
||||||
{{ "%.6f"|format(record.longitude) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #6c757d; font-style: italic;">Not available</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- NEW: Accuracy Column -->
|
|
||||||
<td>
|
|
||||||
{% if record.location_accuracy %}
|
|
||||||
{% set accuracy_class = 'high' if record.location_accuracy <= 50 else ('medium' if record.location_accuracy <= 100 else 'low') %}
|
|
||||||
<span class="accuracy-badge accuracy-{{ accuracy_class }}">
|
|
||||||
±{{ "%.0f"|format(record.location_accuracy) }}m
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #6c757d;">-</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- NEW: Address Column -->
|
|
||||||
<td>
|
|
||||||
{% if record.address %}
|
|
||||||
<div class="address-info" title="{{ record.address }}">
|
|
||||||
{{ record.address }}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #6c757d; font-style: italic;">Not resolved</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div class="device-info" title="{{ record.user_agent }}">
|
|
||||||
<i class="fas fa-{{ 'mobile-alt' if 'Mobile' in record.device_info else 'desktop' }}"></i>
|
|
||||||
{{ record.device_info }}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-badge {{ record.status }}">
|
<span class="status-badge {{ record.status }}">
|
||||||
<i class="fas fa-{{ 'check' if record.status == 'present' else 'times' }}"></i>
|
<i class="fas {{ 'fa-check-circle' if record.status == 'present' else 'fa-times-circle' }}"></i>
|
||||||
{{ record.status.title() }}
|
{{ record.status.title() }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="record-actions">
|
<div class="record-actions">
|
||||||
<button onclick="viewRecord('{{ record.id }}')"
|
<button onclick="viewRecordDetails({{ record.id }})"
|
||||||
class="action-btn"
|
class="action-btn btn-view"
|
||||||
title="View Details">
|
title="View Details">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
{% if record.latitude and record.longitude %}
|
<button onclick="editRecord({{ record.id }})"
|
||||||
<a href="https://www.google.com/maps?q={{ record.latitude }},{{ record.longitude }}"
|
class="action-btn btn-edit"
|
||||||
target="_blank"
|
title="Edit Record">
|
||||||
class="map-link"
|
<i class="fas fa-edit"></i>
|
||||||
title="View on Map">
|
</button>
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<button onclick="deleteRecord({{ record.id }})"
|
||||||
</a>
|
class="action-btn btn-delete"
|
||||||
{% endif %}
|
title="Delete Record">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -423,39 +255,75 @@
|
|||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-search"></i>
|
<div class="empty-icon">
|
||||||
<h3>No Records Found</h3>
|
<i class="fas fa-clipboard-list"></i>
|
||||||
|
</div>
|
||||||
|
<h3>No Attendance Records Found</h3>
|
||||||
<p>{% if date_filter or location_filter or employee_filter %}
|
<p>{% if date_filter or location_filter or employee_filter %}
|
||||||
No attendance records match your current filters.
|
No records match your current filters. Try adjusting the filter criteria.
|
||||||
{% else %}
|
{% else %}
|
||||||
No attendance records available yet.
|
No staff have checked in yet. QR codes need to be scanned to generate attendance data.
|
||||||
{% endif %}</p>
|
{% endif %}</p>
|
||||||
{% if date_filter or location_filter or employee_filter %}
|
|
||||||
<button onclick="clearFilters()" class="btn btn-primary">
|
<button onclick="clearFilters()" class="btn btn-primary">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-refresh"></i>
|
||||||
Clear Filters
|
Clear Filters
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Record Details Modal -->
|
<!-- Record Details Modal -->
|
||||||
<div id="recordModal" class="modal" style="display: none;">
|
<div id="recordModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>
|
<h3 id="modalTitle">Attendance Record Details</h3>
|
||||||
<i class="fas fa-info-circle"></i>
|
<button class="modal-close" onclick="closeRecordModal()">
|
||||||
Record Details
|
|
||||||
</h3>
|
|
||||||
<button onclick="closeModal()" class="modal-close">
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" id="recordDetails">
|
<div class="modal-body" id="modalBody">
|
||||||
<!-- Record details will be loaded here -->
|
<!-- Dynamic content will be loaded here -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button onclick="closeRecordModal()" class="btn btn-secondary">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,171 +331,4 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/attendance_report.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/attendance_report.js') }}"></script>
|
||||||
<script>
|
|
||||||
// Additional JavaScript for location features
|
|
||||||
|
|
||||||
function clearFilters() {
|
|
||||||
window.location.href = "{{ url_for('attendance_report') }}";
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportToCSV() {
|
|
||||||
// Enhanced CSV export with location data
|
|
||||||
const table = document.getElementById('attendanceTable');
|
|
||||||
if (!table) return;
|
|
||||||
|
|
||||||
let csv = [];
|
|
||||||
const rows = table.querySelectorAll('tr');
|
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
|
||||||
const row = [], cols = rows[i].querySelectorAll('td, th');
|
|
||||||
|
|
||||||
for (let j = 0; j < cols.length - 1; j++) { // Skip actions column
|
|
||||||
let cellText = cols[j].innerText.replace(/"/g, '""');
|
|
||||||
row.push('"' + cellText + '"');
|
|
||||||
}
|
|
||||||
csv.push(row.join(','));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download CSV
|
|
||||||
const csvFile = new Blob([csv.join('\n')], { type: 'text/csv' });
|
|
||||||
const downloadLink = document.createElement('a');
|
|
||||||
downloadLink.download = `attendance_report_${new Date().toISOString().slice(0, 10)}.csv`;
|
|
||||||
downloadLink.href = window.URL.createObjectURL(csvFile);
|
|
||||||
downloadLink.style.display = 'none';
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
function printReport() {
|
|
||||||
window.print();
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewRecord(recordId) {
|
|
||||||
// Show record details in modal
|
|
||||||
const modal = document.getElementById('recordModal');
|
|
||||||
const detailsDiv = document.getElementById('recordDetails');
|
|
||||||
|
|
||||||
// Find the record row
|
|
||||||
const row = document.querySelector(`tr[data-record-id="${recordId}"]`);
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
const cells = row.querySelectorAll('td');
|
|
||||||
|
|
||||||
detailsDiv.innerHTML = `
|
|
||||||
<div class="record-detail-grid">
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Employee ID:</strong>
|
|
||||||
<span>${cells[1].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Location:</strong>
|
|
||||||
<span>${cells[2].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Event:</strong>
|
|
||||||
<span>${cells[3].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Date:</strong>
|
|
||||||
<span>${cells[4].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Time:</strong>
|
|
||||||
<span>${cells[5].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>GPS Status:</strong>
|
|
||||||
<span>${cells[6].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Coordinates:</strong>
|
|
||||||
<span>${cells[7].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Accuracy:</strong>
|
|
||||||
<span>${cells[8].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Address:</strong>
|
|
||||||
<span>${cells[9].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Device:</strong>
|
|
||||||
<span>${cells[10].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<strong>Status:</strong>
|
|
||||||
<span>${cells[11].textContent.trim()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
document.getElementById('recordModal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const modal = document.getElementById('recordModal');
|
|
||||||
if (event.target === modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('📍 Enhanced attendance report with location data loaded');
|
|
||||||
console.log('📊 Location coverage: {{ location_coverage }}%');
|
|
||||||
console.log('📍 Records with GPS: {{ stats.checkins_with_location }} / {{ stats.total_checkins }}');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Additional styles for modal and details */
|
|
||||||
.record-detail-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 15px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item strong {
|
|
||||||
color: #495057;
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item span {
|
|
||||||
color: #212529;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-count {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.record-detail-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide some columns on mobile for better readability */
|
|
||||||
.attendance-table th:nth-child(8),
|
|
||||||
.attendance-table td:nth-child(8),
|
|
||||||
.attendance-table th:nth-child(10),
|
|
||||||
.attendance-table td:nth-child(10) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user