diff --git a/app.py b/app.py
index 40bacf7..53011e7 100644
--- a/app.py
+++ b/app.py
@@ -5282,6 +5282,294 @@ def inject_payroll_utils():
'format_hours': lambda hours: f"{hours:.2f}" if hours else "0.00"
}
+@app.route('/statistics')
+@login_required
+def qr_statistics():
+ """QR Code Statistics Dashboard with comprehensive analytics"""
+ try:
+ # Log statistics page access
+ logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed QR code statistics dashboard")
+
+ # Get filter parameters
+ date_from = request.args.get('date_from', '')
+ date_to = request.args.get('date_to', '')
+ qr_code_filter = request.args.get('qr_code', '')
+ project_filter = request.args.get('project', '')
+
+ # Build date filter
+ date_filter = ""
+ if date_from:
+ date_filter += f" AND ad.check_in_date >= '{date_from}'"
+ if date_to:
+ date_filter += f" AND ad.check_in_date <= '{date_to}'"
+
+ # QR Code filter
+ qr_filter = ""
+ if qr_code_filter:
+ qr_filter = f" AND ad.qr_code_id = {qr_code_filter}"
+
+ # Project filter
+ project_filter_clause = ""
+ if project_filter:
+ project_filter_clause = f" AND qc.project_id = {project_filter}"
+
+ # 1. General Statistics
+ general_stats = db.session.execute(text(f"""
+ SELECT
+ COUNT(*) as total_scans,
+ COUNT(DISTINCT ad.employee_id) as unique_users,
+ COUNT(DISTINCT ad.qr_code_id) as active_qr_codes,
+ COUNT(DISTINCT DATE(ad.check_in_date)) as active_days,
+ COUNT(CASE WHEN ad.check_in_date = CURRENT_DATE THEN 1 END) as today_scans,
+ COUNT(CASE WHEN ad.check_in_date >= DATE_SUB(CURRENT_DATE, INTERVAL 7 DAY) THEN 1 END) as week_scans,
+ COUNT(CASE WHEN ad.latitude IS NOT NULL AND ad.longitude IS NOT NULL THEN 1 END) as gps_enabled_scans
+ FROM attendance_data ad
+ LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
+ WHERE 1=1 {date_filter} {qr_filter} {project_filter_clause}
+ """)).fetchone()
+
+ # 2. Device Statistics
+ device_stats = db.session.execute(text(f"""
+ SELECT
+ CASE
+ WHEN device_info LIKE '%iPhone%' OR device_info LIKE '%iOS%' THEN 'iOS'
+ WHEN device_info LIKE '%Android%' THEN 'Android'
+ WHEN device_info LIKE '%Windows%' THEN 'Windows'
+ WHEN device_info LIKE '%Mac%' OR device_info LIKE '%macOS%' THEN 'macOS'
+ WHEN device_info LIKE '%Linux%' THEN 'Linux'
+ ELSE 'Other'
+ END as device_type,
+ COUNT(*) as scan_count,
+ COUNT(DISTINCT employee_id) as unique_users
+ FROM attendance_data ad
+ LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
+ WHERE device_info IS NOT NULL {date_filter} {qr_filter} {project_filter_clause}
+ GROUP BY device_type
+ ORDER BY scan_count DESC
+ """)).fetchall()
+
+ # 3. Browser Statistics (from User Agent)
+ browser_stats = db.session.execute(text(f"""
+ SELECT
+ CASE
+ WHEN user_agent LIKE '%Chrome%' AND user_agent NOT LIKE '%Edge%' THEN 'Chrome'
+ WHEN user_agent LIKE '%Safari%' AND user_agent NOT LIKE '%Chrome%' THEN 'Safari'
+ WHEN user_agent LIKE '%Firefox%' THEN 'Firefox'
+ WHEN user_agent LIKE '%Edge%' THEN 'Edge'
+ WHEN user_agent LIKE '%Opera%' THEN 'Opera'
+ ELSE 'Other'
+ END as browser_type,
+ COUNT(*) as scan_count,
+ COUNT(DISTINCT employee_id) as unique_users
+ FROM attendance_data ad
+ LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
+ WHERE user_agent IS NOT NULL {date_filter} {qr_filter} {project_filter_clause}
+ GROUP BY browser_type
+ ORDER BY scan_count DESC
+ """)).fetchall()
+
+ # 4. Location Statistics
+ location_stats = db.session.execute(text(f"""
+ SELECT
+ qc.name as qr_name,
+ qc.location as qr_location,
+ qc.location_event,
+ COUNT(*) as total_scans,
+ COUNT(DISTINCT ad.employee_id) as unique_users,
+ COUNT(CASE WHEN ad.latitude IS NOT NULL THEN 1 END) as gps_scans,
+ MIN(ad.check_in_date) as first_scan,
+ MAX(ad.check_in_date) as last_scan
+ FROM attendance_data ad
+ JOIN qr_codes qc ON ad.qr_code_id = qc.id
+ WHERE 1=1 {date_filter} {qr_filter} {project_filter_clause}
+ GROUP BY qc.id, qc.name, qc.location, qc.location_event
+ ORDER BY total_scans DESC
+ """)).fetchall()
+
+ # 5. IP Address Analysis (Top 3 Most Active)
+ ip_stats = db.session.execute(text(f"""
+ SELECT
+ ip_address,
+ COUNT(*) as scan_count,
+ COUNT(DISTINCT employee_id) as unique_users,
+ COUNT(DISTINCT qr_code_id) as qr_codes_used,
+ MIN(check_in_date) as first_scan,
+ MAX(check_in_date) as last_scan
+ FROM attendance_data ad
+ LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
+ WHERE ip_address IS NOT NULL {date_filter} {qr_filter} {project_filter_clause}
+ GROUP BY ip_address
+ ORDER BY scan_count DESC
+ LIMIT 3
+ """)).fetchall()
+
+ # 6. Project Statistics (if projects exist)
+ project_stats = db.session.execute(text(f"""
+ SELECT
+ p.id,
+ p.name as project_name,
+ COUNT(*) as total_scans,
+ COUNT(DISTINCT ad.employee_id) as unique_users,
+ COUNT(DISTINCT ad.qr_code_id) as qr_codes_in_project,
+ AVG(CASE WHEN ad.latitude IS NOT NULL THEN 1.0 ELSE 0.0 END) * 100 as gps_usage_percentage
+ FROM attendance_data ad
+ JOIN qr_codes qc ON ad.qr_code_id = qc.id
+ LEFT JOIN projects p ON qc.project_id = p.id
+ WHERE p.id IS NOT NULL {date_filter} {qr_filter} {project_filter_clause}
+ GROUP BY p.id, p.name
+ ORDER BY total_scans DESC
+ """)).fetchall()
+
+ # Get dropdown options for filters
+ qr_codes_list = db.session.execute(text("""
+ SELECT DISTINCT qc.id, qc.name, qc.location
+ FROM qr_codes qc
+ JOIN attendance_data ad ON qc.id = ad.qr_code_id
+ WHERE qc.active_status = true
+ ORDER BY qc.name
+ """)).fetchall()
+
+ projects_list = db.session.execute(text("""
+ SELECT DISTINCT p.id, p.name
+ FROM projects p
+ JOIN qr_codes qc ON p.id = qc.project_id
+ JOIN attendance_data ad ON qc.id = ad.qr_code_id
+ WHERE p.active_status = true
+ ORDER BY p.name
+ """)).fetchall()
+
+ # Log successful statistics generation
+ logger_handler.logger.info(
+ f"Generated statistics report for user {session.get('username', 'unknown')} "
+ f"with {general_stats.total_scans} total scans. Filters applied: "
+ f"date_from={date_from}, date_to={date_to}, qr_code={qr_code_filter}, project={project_filter}"
+ )
+
+ return render_template('statistics.html',
+ general_stats=general_stats,
+ device_stats=device_stats,
+ browser_stats=browser_stats,
+ location_stats=location_stats,
+ ip_stats=ip_stats,
+ project_stats=project_stats,
+ qr_codes_list=qr_codes_list,
+ projects_list=projects_list,
+ date_from=date_from,
+ date_to=date_to,
+ qr_code_filter=qr_code_filter,
+ project_filter=project_filter,
+ today_date=datetime.now().strftime('%Y-%m-%d'))
+
+ except Exception as e:
+ # Log the error using the correct method
+ logger_handler.log_database_error('statistics_page_error', e)
+ print(f"❌ Error loading statistics: {e}")
+ print(f"❌ Traceback: {traceback.format_exc()}")
+
+ flash('Error loading statistics. Please try again.', 'error')
+ return redirect(url_for('dashboard'))
+
+
+@app.route('/api/statistics/export')
+@login_required
+def export_statistics():
+ """Export statistics data to CSV/Excel"""
+ try:
+ # Check permissions
+ if session.get('role') not in ['admin', 'payroll']:
+ return jsonify({'error': 'Access denied'}), 403
+
+ # Log export attempt
+ logger_handler.logger.info(
+ f"User {session.get('username', 'unknown')} (role: {session.get('role')}) "
+ f"attempted to export statistics data in {request.args.get('format', 'csv')} format"
+ )
+
+ # Get comprehensive statistics for export
+ export_data = db.session.execute(text("""
+ SELECT
+ ad.id,
+ ad.employee_id,
+ COALESCE(CONCAT(e.firstName, ' ', e.lastName), ad.employee_id) as employee_name,
+ ad.check_in_date,
+ ad.check_in_time,
+ qc.name as qr_code_name,
+ qc.location as qr_location,
+ qc.location_event,
+ p.name as project_name,
+ ad.device_info,
+ ad.user_agent,
+ ad.ip_address,
+ ad.latitude,
+ ad.longitude,
+ ad.address,
+ ad.location_name,
+ ad.created_timestamp
+ FROM attendance_data ad
+ JOIN qr_codes qc ON ad.qr_code_id = qc.id
+ LEFT JOIN projects p ON qc.project_id = p.id
+ LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
+ ORDER BY ad.created_timestamp DESC
+ """)).fetchall()
+
+ # Create CSV content
+ import csv
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ # Write headers
+ writer.writerow([
+ 'ID', 'Employee ID', 'Employee Name', 'Date', 'Time',
+ 'QR Code', 'QR Location', 'Event', 'Project', 'Device',
+ 'Browser Info', 'IP Address', 'Latitude', 'Longitude',
+ 'Address', 'Location Name', 'Timestamp'
+ ])
+
+ # Write data
+ for row in export_data:
+ writer.writerow([
+ row.id, row.employee_id, row.employee_name,
+ str(row.check_in_date), str(row.check_in_time),
+ row.qr_code_name, row.qr_location, row.location_event,
+ row.project_name or 'No Project', row.device_info or 'Unknown',
+ row.user_agent or 'Unknown', row.ip_address or 'Unknown',
+ row.latitude or '', row.longitude or '',
+ row.address or '', row.location_name or '',
+ str(row.created_timestamp)
+ ])
+
+ output.seek(0)
+
+ # Create response with proper file handling
+ csv_data = output.getvalue()
+
+ # Log successful export
+ logger_handler.logger.info(
+ f"User {session.get('username', 'unknown')} successfully exported "
+ f"{len(export_data)} statistics records"
+ )
+
+ # Create response
+ response = app.make_response(csv_data)
+ response.headers["Content-Disposition"] = f"attachment; filename=qr_statistics_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
+ response.headers["Content-type"] = "text/csv"
+
+ return response
+
+ except Exception as e:
+ logger_handler.log_database_error('statistics_export_error', e)
+ print(f"❌ Error exporting statistics: {e}")
+ return jsonify({'error': 'Export failed'}), 500
+
+ except Exception as e:
+ # Log the error
+ logger_handler.log_database_error('statistics_page_error', e)
+ print(f"❌ Error loading statistics: {e}")
+ print(f"❌ Traceback: {traceback.format_exc()}")
+
+ flash('Error loading statistics. Please try again.', 'error')
+ return redirect(url_for('dashboard'))
+
# Jinja2 filters for better template functionality
@app.template_filter('days_since')
def days_since_filter(date):
diff --git a/static/css/statistics.css b/static/css/statistics.css
new file mode 100644
index 0000000..216e7fd
--- /dev/null
+++ b/static/css/statistics.css
@@ -0,0 +1,774 @@
+/* Statistics Page Styles - static/css/statistics.css */
+
+:root {
+ --primary-color: #3b82f6;
+ --primary-hover: #2563eb;
+ --success-color: #10b981;
+ --warning-color: #f59e0b;
+ --danger-color: #ef4444;
+ --info-color: #06b6d4;
+ --gray-50: #f9fafb;
+ --gray-100: #f3f4f6;
+ --gray-200: #e5e7eb;
+ --gray-300: #d1d5db;
+ --gray-400: #9ca3af;
+ --gray-500: #6b7280;
+ --gray-600: #4b5563;
+ --gray-700: #374151;
+ --gray-800: #1f2937;
+ --gray-900: #111827;
+ --white: #ffffff;
+ --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --transition: all 0.2s ease-in-out;
+ --radius: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-2xl: 1.5rem;
+ --spacing-2: 0.5rem;
+ --spacing-3: 0.75rem;
+ --spacing-4: 1rem;
+ --spacing-6: 1.5rem;
+ --spacing-8: 2rem;
+ --font-size-sm: 0.875rem;
+ --font-size-base: 1rem;
+ --font-size-lg: 1.125rem;
+ --font-size-xl: 1.25rem;
+ --font-size-2xl: 1.5rem;
+ --font-size-3xl: 2rem;
+}
+
+/* Collapsible Sections */
+.section-header {
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
+ color: var(--white);
+ padding: var(--spacing-4) var(--spacing-6);
+ border-radius: var(--radius-xl);
+ margin-bottom: var(--spacing-6);
+ cursor: pointer;
+ transition: var(--transition);
+ user-select: none;
+}
+
+.section-header:hover {
+ background: linear-gradient(135deg, var(--primary-hover), #1d4ed8);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-lg);
+}
+
+.section-header h2 {
+ margin: 0;
+ font-size: var(--font-size-xl);
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-3);
+}
+
+.toggle-icon {
+ transition: transform 0.3s ease;
+ font-size: var(--font-size-base);
+}
+
+.toggle-icon.rotated {
+ transform: rotate(180deg);
+}
+
+.section-content {
+ max-height: none;
+ opacity: 1;
+ transition: all 0.3s ease;
+ overflow: hidden;
+}
+
+.section-content.collapsed {
+ max-height: 0;
+ opacity: 0;
+ margin-bottom: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+/* Chart No Data Message */
+.no-data-message {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--gray-400);
+ text-align: center;
+}
+
+.no-data-message i {
+ font-size: 3rem;
+ margin-bottom: var(--spacing-4);
+ color: var(--gray-300);
+}
+
+.no-data-message p {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ color: var(--gray-500);
+}
+
+/* Collapsible Table Headers */
+.table-header.collapsible {
+ background: linear-gradient(135deg, var(--gray-100), var(--gray-200));
+ color: var(--gray-700);
+ cursor: pointer;
+ transition: var(--transition);
+ user-select: none;
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+}
+
+.table-header.collapsible:hover {
+ background: linear-gradient(135deg, var(--gray-200), var(--gray-300));
+ color: var(--gray-800);
+}
+
+.table-header.collapsible h3 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.table-header.collapsible .toggle-icon {
+ margin-left: auto;
+ margin-right: var(--spacing-4);
+}
+
+/* Update table card structure for collapsible */
+.table-card {
+ background: var(--white);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ border: 1px solid var(--gray-200);
+ margin-bottom: var(--spacing-6);
+}
+
+/* Ensure section content collapses properly */
+.section-content.collapsed {
+ max-height: 0 !important;
+ opacity: 0 !important;
+ margin-bottom: 0 !important;
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+ overflow: hidden !important;
+}
+
+/* Fix the chevron animation */
+.toggle-icon {
+ transition: transform 0.3s ease !important;
+ font-size: var(--font-size-base);
+ display: inline-block;
+}
+
+.toggle-icon.rotated {
+ transform: rotate(180deg) !important;
+}
+.statistics-page {
+ padding: var(--spacing-6);
+ background: var(--gray-50);
+ min-height: 100vh;
+}
+
+/* Header Section */
+.statistics-header {
+ background: linear-gradient(135deg, var(--white) 0%, var(--gray-50) 100%);
+ border-radius: var(--radius-2xl);
+ padding: var(--spacing-8);
+ margin-bottom: var(--spacing-8);
+ box-shadow: var(--shadow);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border: 1px solid var(--gray-200);
+}
+
+.header-content h1 {
+ font-size: var(--font-size-3xl);
+ font-weight: 700;
+ color: var(--gray-900);
+ margin-bottom: var(--spacing-2);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-3);
+}
+
+.header-content h1 i {
+ color: var(--primary-color);
+ font-size: 2.5rem;
+}
+
+.header-content p {
+ color: var(--gray-600);
+ font-size: var(--font-size-lg);
+ margin: 0;
+}
+
+.header-actions {
+ display: flex;
+ gap: var(--spacing-3);
+ flex-wrap: wrap;
+}
+
+/* Filters Section */
+.filters-section {
+ margin-bottom: var(--spacing-8);
+}
+
+.filters-card {
+ background: var(--white);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ border: 1px solid var(--gray-200);
+}
+
+.filters-header {
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
+ color: var(--white);
+ padding: var(--spacing-6);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.filters-header h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-3);
+}
+
+.filters-form {
+ padding: var(--spacing-6);
+}
+
+.filter-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: var(--spacing-6);
+ margin-bottom: var(--spacing-6);
+}
+
+.filter-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-2);
+}
+
+.filter-group label {
+ font-weight: 600;
+ color: var(--gray-700);
+ font-size: var(--font-size-sm);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+}
+
+.filter-group label i {
+ color: var(--primary-color);
+ width: 16px;
+}
+
+.filter-group input,
+.filter-group select {
+ padding: var(--spacing-3);
+ border: 2px solid var(--gray-200);
+ border-radius: var(--radius);
+ font-size: var(--font-size-base);
+ transition: var(--transition);
+ background: var(--white);
+}
+
+.filter-group input:focus,
+.filter-group select:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.filter-actions {
+ display: flex;
+ gap: var(--spacing-3);
+ flex-wrap: wrap;
+}
+
+/* Statistics Overview */
+.stats-overview {
+ margin-bottom: var(--spacing-8);
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: var(--spacing-6);
+}
+
+.stat-card {
+ background: var(--white);
+ border-radius: var(--radius-xl);
+ padding: var(--spacing-6);
+ box-shadow: var(--shadow);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-4);
+ transition: var(--transition);
+ border: 1px solid var(--gray-200);
+ position: relative;
+ overflow: hidden;
+}
+
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+}
+
+.stat-card.primary::before { background: var(--primary-color); }
+.stat-card.success::before { background: var(--success-color); }
+.stat-card.warning::before { background: var(--warning-color); }
+.stat-card.info::before { background: var(--info-color); }
+
+.stat-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+.stat-icon {
+ width: 60px;
+ height: 60px;
+ border-radius: var(--radius-lg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ color: var(--white);
+ flex-shrink: 0;
+}
+
+.stat-card.primary .stat-icon { background: var(--primary-color); }
+.stat-card.success .stat-icon { background: var(--success-color); }
+.stat-card.warning .stat-icon { background: var(--warning-color); }
+.stat-card.info .stat-icon { background: var(--info-color); }
+
+.stat-content {
+ flex: 1;
+}
+
+.stat-content h3 {
+ font-size: var(--font-size-2xl);
+ font-weight: 700;
+ color: var(--gray-900);
+ margin: 0 0 var(--spacing-2) 0;
+}
+
+.stat-content p {
+ color: var(--gray-600);
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ margin: 0 0 var(--spacing-2) 0;
+}
+
+.stat-change {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+ font-size: var(--font-size-sm);
+ color: var(--gray-500);
+}
+
+/* Charts Section */
+.charts-section {
+ margin-bottom: var(--spacing-8);
+}
+
+.charts-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ gap: var(--spacing-6);
+}
+
+.chart-card {
+ background: var(--white);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ border: 1px solid var(--gray-200);
+}
+
+.chart-card.full-width {
+ grid-column: 1 / -1;
+}
+
+.chart-header {
+ background: var(--gray-50);
+ padding: var(--spacing-6);
+ border-bottom: 1px solid var(--gray-200);
+}
+
+.chart-header h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--gray-900);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-3);
+}
+
+.chart-header h3 i {
+ color: var(--primary-color);
+}
+
+.chart-container {
+ padding: var(--spacing-6);
+ height: 350px;
+ position: relative;
+}
+
+.chart-container.compact {
+ height: 250px;
+}
+
+/* Tables Section */
+.tables-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-8);
+}
+
+.table-card {
+ background: var(--white);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ border: 1px solid var(--gray-200);
+}
+
+.table-header {
+ background: var(--gray-50);
+ padding: var(--spacing-6);
+ border-bottom: 1px solid var(--gray-200);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.table-header h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--gray-900);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-3);
+}
+
+.table-header h3 i {
+ color: var(--primary-color);
+}
+
+.table-count {
+ background: var(--primary-color);
+ color: var(--white);
+ padding: 0.25rem 0.75rem;
+ border-radius: var(--radius);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+}
+
+.table-container {
+ overflow-x: auto;
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+}
+
+.data-table th {
+ background: var(--gray-100);
+ color: var(--gray-700);
+ padding: var(--spacing-4);
+ text-align: left;
+ font-weight: 600;
+ border-bottom: 2px solid var(--gray-200);
+ white-space: nowrap;
+}
+
+.data-table td {
+ padding: var(--spacing-4);
+ border-bottom: 1px solid var(--gray-200);
+ color: var(--gray-700);
+ vertical-align: middle;
+}
+
+.data-table tbody tr:hover {
+ background: var(--gray-50);
+}
+
+/* Metric Badges */
+.metric-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.75rem;
+ border-radius: var(--radius);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--white);
+}
+
+.metric-badge.primary { background: var(--primary-color); }
+.metric-badge.success { background: var(--success-color); }
+.metric-badge.info { background: var(--info-color); }
+.metric-badge.warning { background: var(--warning-color); }
+
+/* Progress Bars */
+.progress-bar {
+ position: relative;
+ background: var(--gray-200);
+ border-radius: var(--radius);
+ height: 24px;
+ overflow: hidden;
+ min-width: 100px;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--success-color), #059669);
+ transition: width 0.3s ease;
+}
+
+.progress-text {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--gray-700);
+ text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
+}
+
+/* Special Elements */
+.ip-address {
+ background: var(--gray-100);
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius);
+ font-family: 'Monaco', 'Menlo', monospace;
+ font-size: var(--font-size-sm);
+}
+
+.user-info strong {
+ display: block;
+ color: var(--gray-900);
+}
+
+.user-info small {
+ color: var(--gray-500);
+ font-size: 0.75rem;
+}
+
+.activity-score .score {
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.activity-score .score.high {
+ background: var(--success-color);
+ color: var(--white);
+}
+
+.activity-score .score.medium {
+ background: var(--warning-color);
+ color: var(--white);
+}
+
+.activity-score .score.low {
+ background: var(--gray-400);
+ color: var(--white);
+}
+
+.performance-indicator .perf {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ text-transform: capitalize;
+ margin-bottom: 0.25rem;
+}
+
+.performance-indicator .perf.excellent {
+ background: var(--success-color);
+ color: var(--white);
+}
+
+.performance-indicator .perf.good {
+ background: var(--info-color);
+ color: var(--white);
+}
+
+.performance-indicator .perf.fair {
+ background: var(--warning-color);
+ color: var(--white);
+}
+
+.performance-indicator .perf.poor {
+ background: var(--danger-color);
+ color: var(--white);
+}
+
+.performance-indicator small {
+ display: block;
+ color: var(--gray-500);
+ font-size: 0.75rem;
+}
+
+/* Button Styles */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2);
+ padding: var(--spacing-3) var(--spacing-6);
+ border: none;
+ border-radius: var(--radius);
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ text-decoration: none;
+ cursor: pointer;
+ transition: var(--transition);
+ white-space: nowrap;
+}
+
+.btn-primary {
+ background: var(--primary-color);
+ color: var(--white);
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+}
+
+.btn-success {
+ background: var(--success-color);
+ color: var(--white);
+}
+
+.btn-success:hover {
+ background: #059669;
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--gray-600);
+ color: var(--white);
+}
+
+.btn-secondary:hover {
+ background: var(--gray-700);
+ transform: translateY(-1px);
+}
+
+.btn-outline {
+ background: transparent;
+ color: var(--gray-600);
+ border: 2px solid var(--gray-300);
+}
+
+.btn-outline:hover {
+ background: var(--gray-50);
+ border-color: var(--gray-400);
+ color: var(--gray-700);
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .statistics-page {
+ padding: var(--spacing-4);
+ }
+
+ .statistics-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--spacing-4);
+ text-align: center;
+ }
+
+ .header-actions {
+ justify-content: center;
+ }
+
+ .filter-row {
+ grid-template-columns: 1fr;
+ gap: var(--spacing-4);
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .charts-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .chart-container {
+ height: 250px;
+ }
+
+ .data-table {
+ font-size: 0.75rem;
+ }
+
+ .data-table th,
+ .data-table td {
+ padding: var(--spacing-2);
+ }
+}
+
+@media (max-width: 480px) {
+ .header-content h1 {
+ font-size: var(--font-size-2xl);
+ }
+
+ .stat-card {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .stat-icon {
+ margin-bottom: var(--spacing-2);
+ }
+}
+
+/* Print Styles */
+@media print {
+ .statistics-page {
+ background: white;
+ }
+
+ .header-actions,
+ .filters-section {
+ display: none;
+ }
+
+ .chart-card,
+ .table-card {
+ break-inside: avoid;
+ margin-bottom: var(--spacing-4);
+ }
+}
\ No newline at end of file
diff --git a/templates/base_authenticated.html b/templates/base_authenticated.html
index c1527be..75aff14 100644
--- a/templates/base_authenticated.html
+++ b/templates/base_authenticated.html
@@ -72,6 +72,10 @@
+