From 99705697eb22eae86cf0e215594ee53bc82de93b Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Tue, 26 Aug 2025 15:55:47 -0400 Subject: [PATCH] Add statistics page --- app.py | 288 +++++++++++ static/css/statistics.css | 774 ++++++++++++++++++++++++++++++ templates/base_authenticated.html | 4 + templates/statistics.html | 659 +++++++++++++++++++++++++ 4 files changed, 1725 insertions(+) create mode 100644 static/css/statistics.css create mode 100644 templates/statistics.html 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 @@ Payroll + + + Statistics + diff --git a/templates/statistics.html b/templates/statistics.html new file mode 100644 index 0000000..9b9f48b --- /dev/null +++ b/templates/statistics.html @@ -0,0 +1,659 @@ +{% extends "base_authenticated.html" %} + +{% block title %}QR Code Statistics - QR Code Management{% endblock %} + +{% block extra_head %} + + + + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + QR Code Analytics +

+

Comprehensive insights into QR code usage, devices, locations, and user behavior

+
+ +
+ +
+
+ + +
+ + +
+
+

+ + Overview Statistics + +

+
+
+
+
+
+ +
+
+

{{ "{:,}".format(general_stats.total_scans) }}

+

Total Scans

+ + + {{ general_stats.today_scans }} today + +
+
+ +
+
+ +
+
+

{{ "{:,}".format(general_stats.unique_users) }}

+

Unique Users

+ + + Across {{ general_stats.active_days }} days + +
+
+ +
+
+ +
+
+

{{ general_stats.active_qr_codes }}

+

Active QR Codes

+ + + Currently in use + +
+
+ +
+
+ +
+
+

{{ "{:.1f}".format((general_stats.gps_enabled_scans / general_stats.total_scans * 100) if general_stats.total_scans > 0 else 0) }}%

+

GPS Usage

+ + + {{ "{:,}".format(general_stats.gps_enabled_scans) }} with location + +
+
+
+
+
+ + +
+
+

+ + Analytics Charts + +

+
+
+
+ +
+
+

+ + Device Distribution +

+
+
+ {% if device_stats %} + + {% else %} +
+ +

No device data available

+
+ {% endif %} +
+
+ + +
+
+

+ + Browser Usage +

+
+
+ {% if browser_stats %} + + {% else %} +
+ +

No browser data available

+
+ {% endif %} +
+
+
+
+
+ + +
+ +
+
+

+ + QR Code Location Analytics + +

+ {{ location_stats|length }} locations +
+
+ + + + + + + + + + + + + + + {% for location in location_stats %} + + + + + + + + + + + {% endfor %} + +
QR CodeLocationEventTotal ScansUnique UsersGPS ScansDate RangeGPS Rate
+ {{ location.qr_name }} + {{ location.qr_location }}{{ location.location_event }} + + {{ "{:,}".format(location.total_scans) }} + + + + {{ location.unique_users }} + + + + {{ location.gps_scans }} + + + {{ location.first_scan.strftime('%m/%d') }} - {{ location.last_scan.strftime('%m/%d') }} + + {% set gps_rate = (location.gps_scans / location.total_scans * 100) if location.total_scans > 0 else 0 %} +
+
+ {{ "{:.0f}".format(gps_rate) }}% +
+
+
+
+ + +
+
+

+ + IP Address Analysis (Top 3) + +

+ {{ ip_stats|length }} unique IPs +
+
+ + + + + + + + + + + + + + {% for ip in ip_stats %} + + + + + + + + + + {% endfor %} + +
IP AddressTotal ScansUnique UsersQR Codes UsedFirst SeenLast SeenActivity Score
+ {{ ip.ip_address }} + + + {{ "{:,}".format(ip.scan_count) }} + + + + {{ ip.unique_users }} + + + + {{ ip.qr_codes_used }} + + {{ ip.first_scan.strftime('%m/%d/%Y') }}{{ ip.last_scan.strftime('%m/%d/%Y') }} + {% set activity_score = (ip.scan_count * ip.unique_users * ip.qr_codes_used) %} +
+ {% if activity_score > 100 %} + High + {% elif activity_score > 20 %} + Medium + {% else %} + Low + {% endif %} +
+
+
+
+ + + {% if project_stats %} +
+
+

+ + Project Analytics + +

+ {{ project_stats|length }} projects +
+
+ + + + + + + + + + + + + {% for project in project_stats %} + + + + + + + + + {% endfor %} + +
Project NameTotal ScansUnique UsersQR CodesGPS UsagePerformance
+ {{ project.project_name }} + + + {{ "{:,}".format(project.total_scans) }} + + + + {{ project.unique_users }} + + + + {{ project.qr_codes_in_project }} + + +
+
+ {{ "{:.0f}".format(project.gps_usage_percentage) }}% +
+
+ {% set avg_scans_per_qr = (project.total_scans / project.qr_codes_in_project) if project.qr_codes_in_project > 0 else 0 %} +
+ {% if avg_scans_per_qr > 50 %} + Excellent + {% elif avg_scans_per_qr > 20 %} + Good + {% elif avg_scans_per_qr > 5 %} + Fair + {% else %} + Poor + {% endif %} + {{ "{:.1f}".format(avg_scans_per_qr) }} scans/QR +
+
+
+
+ {% endif %} +
+
+ + + +{% endblock %} \ No newline at end of file