Enhance attendance report

This commit is contained in:
Nguyen Ngo
2025-08-01 12:34:07 -04:00
parent e810488e68
commit c07b815b78
6 changed files with 1895 additions and 1431 deletions
+163 -7
View File
@@ -183,6 +183,7 @@ 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"""
@@ -626,6 +627,68 @@ 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):
@@ -1368,40 +1431,97 @@ 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():
"""Attendance report page (Admin only)""" """Enhanced attendance report page with location data"""
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', '')
# Base query using the view # Enhanced query to include location data
query = db.session.execute(text("SELECT * FROM attendance_report WHERE 1=1")) query_sql = """
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 (you can enhance this with proper SQLAlchemy filtering) # Apply filters to SQL query
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()]
# Get attendance statistics # Enhanced statistics including location data
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')
@@ -1410,6 +1530,7 @@ 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,
@@ -1418,6 +1539,8 @@ 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'))
@@ -1656,8 +1779,41 @@ 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")
+258
View File
@@ -0,0 +1,258 @@
#!/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)
+371 -118
View File
@@ -1,8 +1,3 @@
/**
* Attendance Report Page Styles
* static/css/attendance.css
*/
/* Page Container */ /* Page Container */
.attendance-page { .attendance-page {
max-width: 1400px; max-width: 1400px;
@@ -109,7 +104,43 @@
} }
.stat-card.warning::before { .stat-card.warning::before {
background: linear-gradient(90deg, var(--warning-color), #b45309); background: linear-gradient(90deg, var(--warning-color), #d97706);
}
.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 {
@@ -129,20 +160,24 @@
flex-shrink: 0; flex-shrink: 0;
} }
.stat-card.primary .stat-icon { .stat-icon.primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
} }
.stat-card.success .stat-icon { .stat-icon.success {
background: linear-gradient(135deg, var(--success-color), #047857); background: linear-gradient(135deg, var(--success-color), #047857);
} }
.stat-card.info .stat-icon { .stat-icon.info {
background: linear-gradient(135deg, var(--info-color), #0369a1); background: linear-gradient(135deg, var(--info-color), #0369a1);
} }
.stat-card.warning .stat-icon { .stat-icon.warning {
background: linear-gradient(135deg, var(--warning-color), #b45309); background: linear-gradient(135deg, var(--warning-color), #d97706);
}
.stat-icon.danger {
background: linear-gradient(135deg, var(--danger-color), #b91c1c);
} }
.stat-content { .stat-content {
@@ -154,20 +189,13 @@
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);
font-style: italic; margin: 0;
font-weight: 500;
} }
/* Filters Section */ /* Filters Section */
@@ -183,7 +211,7 @@
} }
.filters-header { .filters-header {
padding: var(--spacing-6); padding: var(--spacing-6) var(--spacing-6) var(--spacing-4);
border-bottom: 1px solid var(--gray-200); border-bottom: 1px solid var(--gray-200);
background: var(--gray-50); background: var(--gray-50);
} }
@@ -202,14 +230,11 @@
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;
} }
@@ -220,9 +245,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);
@@ -290,6 +315,12 @@
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;
@@ -354,12 +385,46 @@
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);
} }
@@ -368,6 +433,20 @@
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;
@@ -430,6 +509,129 @@
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;
@@ -462,20 +664,36 @@
/* Action Buttons */ /* Action Buttons */
.record-actions { .record-actions {
display: flex; display: flex;
gap: var(--spacing-1); gap: 4px;
align-items: center;
justify-content: flex-start;
} }
.action-btn { .action-btn {
width: 32px; display: inline-flex;
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;
font-size: var(--font-size-xs); width: 24px;
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 {
@@ -511,28 +729,28 @@
/* Empty State */ /* Empty State */
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: var(--spacing-20); padding: 60px 20px;
color: var(--gray-500); color: #6b7280;
} }
.empty-icon { .empty-state i {
font-size: 4rem; font-size: 4em;
color: var(--gray-300); margin-bottom: 20px;
margin-bottom: var(--spacing-4); color: #d1d5db;
} }
.empty-state h3 { .empty-state h3 {
font-size: var(--font-size-xl); font-size: 1.5em;
color: var(--gray-700); margin-bottom: 10px;
margin-bottom: var(--spacing-2); color: #374151;
} }
.empty-state p { .empty-state p {
font-size: var(--font-size-base); margin-bottom: 20px;
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 */
@@ -634,65 +852,63 @@
/* 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);
z-index: var(--z-modal); display: flex;
backdrop-filter: blur(4px); align-items: center;
justify-content: center;
z-index: 1000;
} }
.modal-content { .modal-content {
position: absolute; background: white;
top: 50%; border-radius: 12px;
left: 50%;
transform: translate(-50%, -50%);
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
width: 90%;
max-width: 600px; max-width: 600px;
width: 90%;
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: var(--spacing-6); padding: 20px;
border-bottom: 1px solid var(--gray-200); border-bottom: 1px solid #e5e7eb;
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;
font-size: var(--font-size-xl); color: #111827;
font-weight: 600; display: flex;
color: var(--gray-900); align-items: center;
gap: 8px;
} }
.modal-close { .modal-close {
background: none; background: none;
border: none; border: none;
font-size: var(--font-size-xl); font-size: 18px;
cursor: pointer; cursor: pointer;
color: var(--gray-500); color: #6b7280;
padding: var(--spacing-2); padding: 4px;
border-radius: var(--radius); border-radius: 4px;
transition: var(--transition); transition: all 0.2s ease;
} }
.modal-close:hover { .modal-close:hover {
background: var(--gray-200); background: #e5e7eb;
color: var(--gray-700); color: #374151;
} }
.modal-body { .modal-body {
padding: var(--spacing-6); padding: 20px;
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
} }
@@ -706,7 +922,81 @@
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);
@@ -765,57 +1055,20 @@
.record-actions { .record-actions {
flex-direction: column; flex-direction: column;
} }
}
@media (max-width: 480px) { /* Hide coordinates and address columns on mobile */
.attendance-table th, .attendance-table th:nth-child(8),
.attendance-table td { .attendance-table td:nth-child(8),
padding: var(--spacing-2); .attendance-table th:nth-child(10),
font-size: var(--font-size-xs); .attendance-table td:nth-child(10) {
}
.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;
} }
.attendance-table-section { .location-data {
box-shadow: none; font-size: 8px;
border: 1px solid #ccc; padding: 1px 3px;
} }
.action-btn { .coordinates {
display: none; display: none; /* Hide coordinates on mobile */
}
.pagination-container {
display: none;
}
} }
+264 -562
View File
@@ -1,6 +1,6 @@
/** /**
* Attendance Report JavaScript * Enhanced Attendance Report JavaScript with Location Support
* Handles filtering, sorting, pagination, and analytics for attendance data * Fixed version - removes undefined function calls
*/ */
// Global variables // Global variables
@@ -11,144 +11,163 @@ let sortDirection = 'asc';
let attendanceData = []; let attendanceData = [];
let filteredData = []; let filteredData = [];
// Charts // Charts (if you want to add them later)
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('Attendance Report page initialized'); console.log('Enhanced Attendance Report page initialized');
initializeReport(); initializeReport();
loadAttendanceData();
initializeCharts();
setupEventListeners(); setupEventListeners();
// Initialize location-specific features
initializeLocationFeatures();
}); });
function initializeReport() { function initializeReport() {
// Load data from table console.log('📊 Initializing attendance report...');
// Load data from existing table
loadTableData(); loadTableData();
// Initialize pagination // Initialize pagination if needed
updatePagination(); updatePagination();
// Apply initial filters if any // Apply any initial filters
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, id: row.dataset.recordId || index,
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() : '',
device: cells[6] ? cells[6].getAttribute('title') || cells[6].textContent.trim() : '', // Location data (new columns)
status: cells[7] ? cells[7].textContent.trim() : '', gpsStatus: cells[6] ? cells[6].textContent.trim() : '',
element: row coordinates: cells[7] ? cells[7].textContent.trim() : '',
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 change // Entries per page selector
const entriesSelect = document.getElementById('entriesPerPage'); const entriesSelect = document.getElementById('entriesPerPage');
if (entriesSelect) { if (entriesSelect) {
entriesSelect.addEventListener('change', changeEntriesPerPage); entriesSelect.addEventListener('change', changeEntriesPerPage);
} }
// Filter form // Search functionality (if you have a search input)
const filtersForm = document.getElementById('filtersForm'); const searchInput = document.getElementById('searchInput');
if (filtersForm) { if (searchInput) {
filtersForm.addEventListener('submit', function(e) { searchInput.addEventListener('input', handleSearch);
e.preventDefault(); }
applyFilters();
// 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);
});
}); });
}
// Real-time employee filter // Add click handlers for view record buttons
const employeeFilter = document.getElementById('employee'); document.querySelectorAll('.action-btn[title*="View"]').forEach(btn => {
if (employeeFilter) { btn.addEventListener('click', function(e) {
employeeFilter.addEventListener('input', debounce(applyFilters, 300)); e.preventDefault();
} const recordId = this.closest('tr').dataset.recordId;
if (recordId) {
// Date and location filters viewRecord(recordId);
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 applyFilters() { function handleSearch(event) {
const dateFilter = document.getElementById('date')?.value || ''; const searchTerm = event.target.value.toLowerCase().trim();
const locationFilter = document.getElementById('location')?.value || '';
const employeeFilter = document.getElementById('employee')?.value.toLowerCase() || '';
filteredData = attendanceData.filter(record => { if (searchTerm === '') {
const matchesDate = !dateFilter || record.date === dateFilter;
const matchesLocation = !locationFilter || record.location === locationFilter;
const matchesEmployee = !employeeFilter ||
record.employeeId.toLowerCase().includes(employeeFilter);
return matchesDate && matchesLocation && matchesEmployee;
});
currentPage = 1;
updateTable();
updatePagination();
updateFilterStats();
}
function clearFilters() {
// Clear form inputs
const form = document.getElementById('filtersForm');
if (form) {
form.reset();
}
// Reset filtered data
filteredData = [...attendanceData]; filteredData = [...attendanceData];
} else {
filteredData = attendanceData.filter(record =>
record.employeeId.toLowerCase().includes(searchTerm) ||
record.location.toLowerCase().includes(searchTerm) ||
record.event.toLowerCase().includes(searchTerm) ||
record.address.toLowerCase().includes(searchTerm)
);
}
currentPage = 1; currentPage = 1;
displayPage();
// Update display
updateTable();
updatePagination(); updatePagination();
updateFilterStats();
// Update URL without filters console.log(`🔍 Search results: ${filteredData.length} records found`);
const url = new URL(window.location); }
url.search = '';
window.history.pushState({}, '', url); function handleFilterSubmit(event) {
// Let the form submit normally to reload with filters
console.log('🔽 Applying filters...');
} }
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 {
@@ -156,568 +175,251 @@ function sortTable(columnIndex) {
sortDirection = 'asc'; sortDirection = 'asc';
} }
filteredData.sort((a, b) => { // Update sort indicators
let aVal = a[column]; updateSortIndicators();
let bVal = b[column];
// Handle different data types // Sort the data
if (column === 'date' || column === 'time') { const sortKey = getSortKey(columnIndex);
aVal = new Date(column === 'date' ? aVal : `2000-01-01 ${aVal}`); if (sortKey) {
bVal = new Date(column === 'date' ? bVal : `2000-01-01 ${bVal}`); filteredData.sort((a, b) => {
} else if (column === 'index') { let aVal = a[sortKey] || '';
aVal = parseInt(aVal); let bVal = b[sortKey] || '';
bVal = parseInt(bVal);
} else { // Convert to strings for comparison
aVal = aVal.toString().toLowerCase(); aVal = aVal.toString().toLowerCase();
bVal = bVal.toString().toLowerCase(); bVal = bVal.toString().toLowerCase();
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; if (sortDirection === 'asc') {
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; return aVal.localeCompare(bVal);
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 {
icon.className = 'fas fa-sort'; 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';
if (index === sortColumn) {
icon.className = sortDirection === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
} }
}); });
} }
function updateTable() { function displayPage() {
const tbody = document.querySelector('#attendanceTable tbody'); const table = document.getElementById('attendanceTable');
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 = entriesPerPage === filteredData.length ? const endIndex = startIndex + (entriesPerPage === filteredData.length ? filteredData.length : entriesPerPage);
filteredData.length :
Math.min(startIndex + entriesPerPage, filteredData.length);
// Hide all rows first // Show relevant rows
attendanceData.forEach(record => { for (let i = startIndex; i < endIndex && i < filteredData.length; i++) {
if (record.element) { const record = filteredData[i];
record.element.style.display = 'none'; if (record.row) {
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;
}
}
});
// Show empty state if no data
showEmptyStateIfNeeded();
} }
function showEmptyStateIfNeeded() { console.log(`📄 Displaying records ${startIndex + 1}-${Math.min(endIndex, filteredData.length)} of ${filteredData.length}`);
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() {
const container = document.getElementById('paginationContainer'); // This is a placeholder for pagination controls
if (!container) return; // You can implement pagination UI here if needed
const totalPages = Math.ceil(filteredData.length / entriesPerPage); const totalPages = Math.ceil(filteredData.length / entriesPerPage);
console.log(`📄 Page ${currentPage} of ${totalPages}`);
}
if (totalPages <= 1) { function applyFilters() {
container.innerHTML = ''; // Filters are handled by the backend through form submission
// 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 paginationHTML = '<div class="pagination">'; let csv = [];
const rows = table.querySelectorAll('tr');
// Previous button for (let i = 0; i < rows.length; i++) {
paginationHTML += ` const row = [];
<button onclick="goToPage(${currentPage - 1})" const cols = rows[i].querySelectorAll('td, th');
class="pagination-btn"
${currentPage === 1 ? 'disabled' : ''}>
<i class="fas fa-chevron-left"></i>
</button>
`;
// Page numbers for (let j = 0; j < cols.length - 1; j++) { // Skip actions column
const maxVisiblePages = 5; let cellText = cols[j].innerText.replace(/"/g, '""');
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); // Clean up the text (remove extra whitespace, newlines)
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); cellText = cellText.replace(/\s+/g, ' ').trim();
row.push('"' + cellText + '"');
if (endPage - startPage + 1 < maxVisiblePages) { }
startPage = Math.max(1, endPage - maxVisiblePages + 1); csv.push(row.join(','));
} }
if (startPage > 1) { // Download CSV
paginationHTML += `<button onclick="goToPage(1)" class="pagination-btn">1</button>`; const csvFile = new Blob([csv.join('\n')], { type: 'text/csv' });
if (startPage > 2) { const downloadLink = document.createElement('a');
paginationHTML += '<span class="pagination-ellipsis">...</span>'; 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);
console.log('📄 CSV exported successfully');
} }
for (let i = startPage; i <= endPage; i++) { function printReport() {
paginationHTML += ` window.print();
<button onclick="goToPage(${i})" console.log('🖨️ Print dialog opened');
class="pagination-btn ${i === currentPage ? 'active' : ''}">
${i}
</button>
`;
} }
if (endPage < totalPages) { function viewRecord(recordId) {
if (endPage < totalPages - 1) { console.log('👁️ Viewing record:', recordId);
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 modalTitle = document.getElementById('modalTitle'); const detailsDiv = document.getElementById('recordDetails');
const modalBody = document.getElementById('modalBody');
if (!modal || !modalTitle || !modalBody) return; if (!modal || !detailsDiv) {
console.error('❌ Modal elements not found');
return;
}
modalTitle.textContent = `Attendance Record - ${record.employeeId}`; // Find the record row
const row = document.querySelector(`tr[data-record-id="${recordId}"]`);
if (!row) {
console.error('❌ Record row not found');
return;
}
modalBody.innerHTML = ` const cells = row.querySelectorAll('td');
<div class="record-details">
<div class="detail-grid"> detailsDiv.innerHTML = `
<div class="record-detail-grid">
<div class="detail-item"> <div class="detail-item">
<strong>Employee ID:</strong> <strong>Employee ID:</strong>
<span>${record.employeeId}</span> <span>${cells[1] ? cells[1].textContent.trim() : 'N/A'}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<strong>Location:</strong> <strong>Location:</strong>
<span>${record.location}</span> <span>${cells[2] ? cells[2].textContent.trim() : 'N/A'}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<strong>Event:</strong> <strong>Event:</strong>
<span>${record.event}</span> <span>${cells[3] ? cells[3].textContent.trim() : 'N/A'}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<strong>Date:</strong> <strong>Date:</strong>
<span>${record.date}</span> <span>${cells[4] ? cells[4].textContent.trim() : 'N/A'}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<strong>Time:</strong> <strong>Time:</strong>
<span>${record.time}</span> <span>${cells[5] ? cells[5].textContent.trim() : 'N/A'}</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>${record.device}</span> <span>${cells[10] ? cells[10].textContent.trim() : 'N/A'}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<strong>Status:</strong> <strong>Status:</strong>
<span class="status-badge ${record.status.toLowerCase()}"> <span>${cells[11] ? cells[11].textContent.trim() : 'N/A'}</span>
${record.status}
</span>
</div>
</div> </div>
</div> </div>
`; `;
modal.style.display = 'flex'; modal.style.display = 'flex';
setTimeout(() => modal.classList.add('show'), 10); console.log('✅ Record modal opened');
} }
function editRecord(recordId) { function closeModal() {
// 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';
}, 200); console.log('❌ Modal closed');
} }
} }
// Export functionality function clearFilters() {
function exportAttendance() { // Get the current URL without query parameters
const exportData = filteredData.map(record => ({ const baseUrl = window.location.origin + window.location.pathname;
'Employee ID': record.employeeId, window.location.href = baseUrl;
'Location': record.location,
'Event': record.event,
'Date': record.date,
'Time': record.time,
'Device': record.device,
'Status': record.status
}));
const csv = convertToCSV(exportData); console.log('🔄 Clearing filters and reloading');
downloadCSV(csv, `attendance_report_${new Date().toISOString().split('T')[0]}.csv`);
showToast('Attendance data exported successfully', 'success');
} }
function convertToCSV(data) { // Global event handlers
if (!data.length) return ''; window.onclick = function(event) {
const modal = document.getElementById('recordModal');
const headers = Object.keys(data[0]); if (event.target === modal) {
const csvContent = [ closeModal();
headers.join(','),
...data.map(row =>
headers.map(header => {
const value = row[header];
// Escape commas and quotes
return typeof value === 'string' && (value.includes(',') || value.includes('"'))
? `"${value.replace(/"/g, '""')}"`
: value;
}).join(',')
)
].join('\n');
return csvContent;
}
function downloadCSV(csv, filename) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
if (link.download !== undefined) {
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} }
} }
function refreshReport() { // Make functions globally available
showToast('Refreshing report...', 'info'); window.exportToCSV = exportToCSV;
setTimeout(() => { window.printReport = printReport;
window.location.reload(); window.viewRecord = viewRecord;
}, 500); window.closeModal = closeModal;
} 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;
window.goToPage = goToPage; console.log('📍 Enhanced Attendance Report JavaScript loaded successfully');
window.viewRecordDetails = viewRecordDetails; console.log('🔧 Available functions: exportToCSV, printReport, viewRecord, sortTable');
window.editRecord = editRecord;
window.deleteRecord = deleteRecord;
window.closeRecordModal = closeRecordModal;
window.exportAttendance = exportAttendance;
window.refreshReport = refreshReport;
File diff suppressed because it is too large Load Diff
+405 -106
View File
@@ -1,12 +1,122 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Attendance Report - QR Code Management{% endblock %} {% block title %}Attendance Report{% endblock %}
{% block extra_head %} {% block extra_css %}
<!-- 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') }}">
<!-- Chart.js for analytics --> <style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script> /* Additional styles for location data */
.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 %}
@@ -18,65 +128,66 @@
<i class="fas fa-chart-line"></i> <i class="fas fa-chart-line"></i>
Attendance Report Attendance Report
</h1> </h1>
<p>Monitor and analyze staff attendance across all locations</p> <p>Comprehensive attendance tracking with location data for {{ current_date_formatted }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button onclick="exportAttendance()" class="btn btn-success"> <button onclick="exportToCSV()" class="btn btn-outline">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
Export Data Export CSV
</button> </button>
<button onclick="refreshReport()" class="btn btn-secondary"> <button onclick="printReport()" class="btn btn-outline">
<i class="fas fa-sync-alt"></i> <i class="fas fa-print"></i>
Refresh Print Report
</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 Cards --> <!-- Statistics Section -->
<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-user-check"></i> <i class="fas fa-users"></i>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<h3>{{ stats.total_checkins or 0 }}</h3> <h3>{{ stats.total_checkins }}</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-users"></i> <i class="fas fa-user-check"></i>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<h3>{{ stats.unique_employees or 0 }}</h3> <h3>{{ stats.unique_employees }}</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-map-marker-alt"></i>
</div>
<div class="stat-content">
<h3>{{ stats.active_locations or 0 }}</h3>
<p>Active Locations</p>
<span class="stat-trend">With check-ins</span>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon"> <div class="stat-icon">
<i class="fas fa-calendar-day"></i> <i class="fas fa-calendar-day"></i>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<h3>{{ stats.today_checkins or 0 }}</h3> <h3>{{ stats.today_checkins }}</h3>
<p>Today's Check-ins</p> <p>Today's Check-ins</p>
<span class="stat-trend">{{ current_date_formatted or 'Today' }}</span> </div>
</div>
<!-- NEW: Location Statistics Card -->
<div class="stat-card location-stats">
<div class="stat-icon">
<i class="fas fa-map-marker-alt"></i>
</div>
<div class="stat-content">
<h3>{{ stats.checkins_with_location }}</h3>
<p>With GPS Location</p>
<small>{{ location_coverage }}% coverage</small>
</div> </div>
</div> </div>
</div> </div>
@@ -92,7 +203,7 @@
</h3> </h3>
</div> </div>
<form method="GET" class="filters-form" id="filtersForm"> <form method="GET" action="{{ url_for('attendance_report') }}">
<div class="filter-row"> <div class="filter-row">
<div class="filter-group"> <div class="filter-group">
<label for="date"> <label for="date">
@@ -103,19 +214,19 @@
id="date" id="date"
name="date" name="date"
value="{{ date_filter }}" value="{{ date_filter }}"
max="{{ today_date or '' }}"> max="{{ today_date }}">
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label for="location"> <label for="location">
<i class="fas fa-building"></i> <i class="fas fa-map-marker-alt"></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 }}"
{{ 'selected' if location == location_filter else '' }}> {% if location == location_filter %}selected{% endif %}>
{{ location }} {{ location }}
</option> </option>
{% endfor %} {% endfor %}
@@ -155,6 +266,9 @@
<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">
@@ -181,8 +295,13 @@
<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>
<th onclick="sortTable(6)">Device <i class="fas fa-sort"></i></th> <!-- NEW: Location Data Columns -->
<th onclick="sortTable(7)">Status <i class="fas fa-sort"></i></th> <th onclick="sortTable(6)">GPS 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>
@@ -216,37 +335,86 @@
{{ record.check_in_time.strftime('%H:%M') }} {{ record.check_in_time.strftime('%H:%M') }}
</div> </div>
</td> </td>
<!-- NEW: GPS Status Column -->
<td> <td>
<div class="device-info"> {% if record.latitude and record.longitude %}
<i class="fas fa-mobile-alt"></i> <span class="location-indicator has-gps">
<span title="{{ record.device_info }}"> <i class="fas fa-satellite"></i>
{{ record.device_info[:20] }}{% if record.device_info|length > 20 %}...{% endif %} GPS
</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-circle' if record.status == 'present' else 'fa-times-circle' }}"></i> <i class="fas fa-{{ 'check' if record.status == 'present' else 'times' }}"></i>
{{ record.status.title() }} {{ record.status.title() }}
</span> </span>
</td> </td>
<td> <td>
<div class="record-actions"> <div class="record-actions">
<button onclick="viewRecordDetails({{ record.id }})" <button onclick="viewRecord('{{ record.id }}')"
class="action-btn btn-view" class="action-btn"
title="View Details"> title="View Details">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</button> </button>
<button onclick="editRecord({{ record.id }})" {% if record.latitude and record.longitude %}
class="action-btn btn-edit" <a href="https://www.google.com/maps?q={{ record.latitude }},{{ record.longitude }}"
title="Edit Record"> target="_blank"
<i class="fas fa-edit"></i> class="map-link"
</button> title="View on Map">
<button onclick="deleteRecord({{ record.id }})" <i class="fas fa-external-link-alt"></i>
class="action-btn btn-delete" </a>
title="Delete Record"> {% endif %}
<i class="fas fa-trash"></i>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -255,75 +423,39 @@
</table> </table>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <i class="fas fa-search"></i>
<i class="fas fa-clipboard-list"></i> <h3>No Records Found</h3>
</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 records match your current filters. Try adjusting the filter criteria. No attendance records match your current filters.
{% else %} {% else %}
No staff have checked in yet. QR codes need to be scanned to generate attendance data. No attendance records available yet.
{% 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-refresh"></i> <i class="fas fa-times"></i>
Clear Filters Clear Filters
</button> </button>
</div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<!-- 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> </div>
<!-- Record Details Modal --> <!-- Record Details Modal -->
<div id="recordModal" class="modal"> <div id="recordModal" class="modal" style="display: none;">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="modalTitle">Attendance Record Details</h3> <h3>
<button class="modal-close" onclick="closeRecordModal()"> <i class="fas fa-info-circle"></i>
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="modalBody"> <div class="modal-body" id="recordDetails">
<!-- Dynamic content will be loaded here --> <!-- Record details will be loaded here -->
</div>
<div class="modal-footer">
<button onclick="closeRecordModal()" class="btn btn-secondary">
Close
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -331,4 +463,171 @@
{% 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 %}