Add statistics page
This commit is contained in:
@@ -5282,6 +5282,294 @@ def inject_payroll_utils():
|
|||||||
'format_hours': lambda hours: f"{hours:.2f}" if hours else "0.00"
|
'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
|
# Jinja2 filters for better template functionality
|
||||||
@app.template_filter('days_since')
|
@app.template_filter('days_since')
|
||||||
def days_since_filter(date):
|
def days_since_filter(date):
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,10 @@
|
|||||||
<i class="fas fa-calculator"></i>
|
<i class="fas fa-calculator"></i>
|
||||||
<span class="menu-text">Payroll</span>
|
<span class="menu-text">Payroll</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('qr_statistics') }}" class="menu-item {% if request.endpoint == 'qr_statistics' %}active{% endif %}">
|
||||||
|
<i class="fas fa-chart-pie"></i>
|
||||||
|
<span class="menu-text">Statistics</span>
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('admin_logs') }}"
|
<a href="{{ url_for('admin_logs') }}"
|
||||||
class="menu-item {% if request.endpoint == 'admin_logs' %}active{% endif %}">
|
class="menu-item {% if request.endpoint == 'admin_logs' %}active{% endif %}">
|
||||||
<i class="fas fa-clipboard-list"></i>
|
<i class="fas fa-clipboard-list"></i>
|
||||||
|
|||||||
@@ -0,0 +1,659 @@
|
|||||||
|
{% extends "base_authenticated.html" %}
|
||||||
|
|
||||||
|
{% block title %}QR Code Statistics - QR Code Management{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<!-- Statistics-specific CSS -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
|
||||||
|
<!-- Chart.js for analytics -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="statistics-page">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="statistics-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>
|
||||||
|
<i class="fas fa-chart-pie"></i>
|
||||||
|
QR Code Analytics
|
||||||
|
</h1>
|
||||||
|
<p>Comprehensive insights into QR code usage, devices, locations, and user behavior</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<button onclick="refreshStatistics()" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Section -->
|
||||||
|
<div class="filters-section">
|
||||||
|
<div class="filters-card">
|
||||||
|
<div class="filters-header">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-filter"></i>
|
||||||
|
Filter Analytics
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="filters-form">
|
||||||
|
<form method="GET" action="{{ url_for('qr_statistics') }}" id="statisticsFilters">
|
||||||
|
<div class="filter-row">
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="date_from">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
From Date
|
||||||
|
</label>
|
||||||
|
<input type="date"
|
||||||
|
id="date_from"
|
||||||
|
name="date_from"
|
||||||
|
value="{{ date_from }}"
|
||||||
|
max="{{ today_date }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="date_to">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
To Date
|
||||||
|
</label>
|
||||||
|
<input type="date"
|
||||||
|
id="date_to"
|
||||||
|
name="date_to"
|
||||||
|
value="{{ date_to }}"
|
||||||
|
max="{{ today_date }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Filter -->
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="qr_code">
|
||||||
|
<i class="fas fa-qrcode"></i>
|
||||||
|
QR Code
|
||||||
|
</label>
|
||||||
|
<select id="qr_code" name="qr_code">
|
||||||
|
<option value="">All QR Codes</option>
|
||||||
|
{% for qr in qr_codes_list %}
|
||||||
|
<option value="{{ qr.id }}"
|
||||||
|
{% if qr_code_filter|string == qr.id|string %}selected{% endif %}>
|
||||||
|
{{ qr.name }} - {{ qr.location }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Filter -->
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="project">
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
Project
|
||||||
|
</label>
|
||||||
|
<select id="project" name="project">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{% for project in projects_list %}
|
||||||
|
<option value="{{ project.id }}"
|
||||||
|
{% if project_filter|string == project.id|string %}selected{% endif %}>
|
||||||
|
{{ project.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('qr_statistics') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
Clear Filters
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview Statistics Cards -->
|
||||||
|
<div class="stats-overview">
|
||||||
|
<div class="section-header collapsible" data-target="overview-content">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-chart-bar"></i>
|
||||||
|
Overview Statistics
|
||||||
|
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="section-content" id="overview-content">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card primary">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-mouse-pointer"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ "{:,}".format(general_stats.total_scans) }}</h3>
|
||||||
|
<p>Total Scans</p>
|
||||||
|
<span class="stat-change">
|
||||||
|
<i class="fas fa-calendar-day"></i>
|
||||||
|
{{ general_stats.today_scans }} today
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card success">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ "{:,}".format(general_stats.unique_users) }}</h3>
|
||||||
|
<p>Unique Users</p>
|
||||||
|
<span class="stat-change">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
Across {{ general_stats.active_days }} days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card warning">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-qrcode"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ general_stats.active_qr_codes }}</h3>
|
||||||
|
<p>Active QR Codes</p>
|
||||||
|
<span class="stat-change">
|
||||||
|
<i class="fas fa-chart-bar"></i>
|
||||||
|
Currently in use
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card info">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ "{:.1f}".format((general_stats.gps_enabled_scans / general_stats.total_scans * 100) if general_stats.total_scans > 0 else 0) }}%</h3>
|
||||||
|
<p>GPS Usage</p>
|
||||||
|
<span class="stat-change">
|
||||||
|
<i class="fas fa-satellite-dish"></i>
|
||||||
|
{{ "{:,}".format(general_stats.gps_enabled_scans) }} with location
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Section -->
|
||||||
|
<div class="charts-section">
|
||||||
|
<div class="section-header collapsible" data-target="charts-content">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-chart-pie"></i>
|
||||||
|
Analytics Charts
|
||||||
|
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="section-content" id="charts-content">
|
||||||
|
<div class="charts-grid">
|
||||||
|
<!-- Device Distribution Chart -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-mobile-alt"></i>
|
||||||
|
Device Distribution
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container compact">
|
||||||
|
{% if device_stats %}
|
||||||
|
<canvas id="deviceChart"></canvas>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-data-message">
|
||||||
|
<i class="fas fa-mobile-alt"></i>
|
||||||
|
<p>No device data available</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browser Distribution Chart -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-globe"></i>
|
||||||
|
Browser Usage
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container compact">
|
||||||
|
{% if browser_stats %}
|
||||||
|
<canvas id="browserChart"></canvas>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-data-message">
|
||||||
|
<i class="fas fa-globe"></i>
|
||||||
|
<p>No browser data available</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Tables Section -->
|
||||||
|
<div class="tables-section">
|
||||||
|
<!-- Location Statistics Table -->
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-header collapsible" data-target="location-table">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
QR Code Location Analytics
|
||||||
|
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||||
|
</h3>
|
||||||
|
<span class="table-count">{{ location_stats|length }} locations</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-container section-content" id="location-table">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>QR Code</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Total Scans</th>
|
||||||
|
<th>Unique Users</th>
|
||||||
|
<th>GPS Scans</th>
|
||||||
|
<th>Date Range</th>
|
||||||
|
<th>GPS Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for location in location_stats %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ location.qr_name }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>{{ location.qr_location }}</td>
|
||||||
|
<td>{{ location.location_event }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge primary">
|
||||||
|
{{ "{:,}".format(location.total_scans) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge success">
|
||||||
|
{{ location.unique_users }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge info">
|
||||||
|
{{ location.gps_scans }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small>{{ location.first_scan.strftime('%m/%d') }} - {{ location.last_scan.strftime('%m/%d') }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set gps_rate = (location.gps_scans / location.total_scans * 100) if location.total_scans > 0 else 0 %}
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {{ gps_rate }}%"></div>
|
||||||
|
<span class="progress-text">{{ "{:.0f}".format(gps_rate) }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IP Address Analysis Table -->
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-header collapsible" data-target="ip-table">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-network-wired"></i>
|
||||||
|
IP Address Analysis (Top 3)
|
||||||
|
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||||
|
</h3>
|
||||||
|
<span class="table-count">{{ ip_stats|length }} unique IPs</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-container section-content" id="ip-table">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Total Scans</th>
|
||||||
|
<th>Unique Users</th>
|
||||||
|
<th>QR Codes Used</th>
|
||||||
|
<th>First Seen</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
<th>Activity Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip in ip_stats %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code class="ip-address">{{ ip.ip_address }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge primary">
|
||||||
|
{{ "{:,}".format(ip.scan_count) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge success">
|
||||||
|
{{ ip.unique_users }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge info">
|
||||||
|
{{ ip.qr_codes_used }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ ip.first_scan.strftime('%m/%d/%Y') }}</td>
|
||||||
|
<td>{{ ip.last_scan.strftime('%m/%d/%Y') }}</td>
|
||||||
|
<td>
|
||||||
|
{% set activity_score = (ip.scan_count * ip.unique_users * ip.qr_codes_used) %}
|
||||||
|
<div class="activity-score" data-score="{{ activity_score }}">
|
||||||
|
{% if activity_score > 100 %}
|
||||||
|
<span class="score high">High</span>
|
||||||
|
{% elif activity_score > 20 %}
|
||||||
|
<span class="score medium">Medium</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="score low">Low</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Statistics Table (if projects exist) -->
|
||||||
|
{% if project_stats %}
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-header collapsible" data-target="project-table">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
Project Analytics
|
||||||
|
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||||
|
</h3>
|
||||||
|
<span class="table-count">{{ project_stats|length }} projects</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-container section-content" id="project-table">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project Name</th>
|
||||||
|
<th>Total Scans</th>
|
||||||
|
<th>Unique Users</th>
|
||||||
|
<th>QR Codes</th>
|
||||||
|
<th>GPS Usage</th>
|
||||||
|
<th>Performance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for project in project_stats %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ project.project_name }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge primary">
|
||||||
|
{{ "{:,}".format(project.total_scans) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge success">
|
||||||
|
{{ project.unique_users }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="metric-badge info">
|
||||||
|
{{ project.qr_codes_in_project }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {{ project.gps_usage_percentage }}%"></div>
|
||||||
|
<span class="progress-text">{{ "{:.0f}".format(project.gps_usage_percentage) }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set avg_scans_per_qr = (project.total_scans / project.qr_codes_in_project) if project.qr_codes_in_project > 0 else 0 %}
|
||||||
|
<div class="performance-indicator">
|
||||||
|
{% if avg_scans_per_qr > 50 %}
|
||||||
|
<span class="perf excellent">Excellent</span>
|
||||||
|
{% elif avg_scans_per_qr > 20 %}
|
||||||
|
<span class="perf good">Good</span>
|
||||||
|
{% elif avg_scans_per_qr > 5 %}
|
||||||
|
<span class="perf fair">Fair</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="perf poor">Poor</span>
|
||||||
|
{% endif %}
|
||||||
|
<small>{{ "{:.1f}".format(avg_scans_per_qr) }} scans/QR</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Chart.js Configuration and Data
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Collapsible functionality
|
||||||
|
document.querySelectorAll('.collapsible').forEach(function(header) {
|
||||||
|
header.addEventListener('click', function() {
|
||||||
|
const targetId = this.getAttribute('data-target');
|
||||||
|
const content = document.getElementById(targetId);
|
||||||
|
const toggleIcon = this.querySelector('.toggle-icon');
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
content.classList.toggle('collapsed');
|
||||||
|
toggleIcon.classList.toggle('rotated');
|
||||||
|
|
||||||
|
// If section is being expanded and contains charts, reinitialize them
|
||||||
|
if (!content.classList.contains('collapsed')) {
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeChartsInSection(content);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chart.js default configuration
|
||||||
|
Chart.defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, sans-serif";
|
||||||
|
Chart.defaults.color = '#6b7280';
|
||||||
|
Chart.defaults.plugins.legend.position = 'bottom';
|
||||||
|
Chart.defaults.plugins.legend.labels.padding = 20;
|
||||||
|
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
||||||
|
|
||||||
|
// Initialize all charts
|
||||||
|
initializeAllCharts();
|
||||||
|
|
||||||
|
function initializeAllCharts() {
|
||||||
|
initializeDeviceChart();
|
||||||
|
initializeBrowserChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeChartsInSection(section) {
|
||||||
|
if (section.querySelector('#deviceChart')) initializeDeviceChart();
|
||||||
|
if (section.querySelector('#browserChart')) initializeBrowserChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeDeviceChart() {
|
||||||
|
const deviceCanvas = document.getElementById('deviceChart');
|
||||||
|
if (!deviceCanvas) return;
|
||||||
|
|
||||||
|
const deviceCtx = deviceCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Check if we have device data
|
||||||
|
const deviceLabels = [
|
||||||
|
{% for device in device_stats %}
|
||||||
|
'{{ device.device_type }}'{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
const deviceData = [
|
||||||
|
{% for device in device_stats %}
|
||||||
|
{{ device.scan_count }}{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (deviceLabels.length === 0 || deviceData.length === 0) {
|
||||||
|
console.log('No device data available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Chart(deviceCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: deviceLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Scans',
|
||||||
|
data: deviceData,
|
||||||
|
backgroundColor: [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
|
const percentage = ((context.parsed.x * 100) / total).toFixed(1);
|
||||||
|
return context.label + ': ' + context.parsed.x.toLocaleString() +
|
||||||
|
' (' + percentage + '%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeBrowserChart() {
|
||||||
|
const browserCanvas = document.getElementById('browserChart');
|
||||||
|
if (!browserCanvas) return;
|
||||||
|
|
||||||
|
const browserCtx = browserCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Check if we have browser data
|
||||||
|
const browserLabels = [
|
||||||
|
{% for browser in browser_stats %}
|
||||||
|
'{{ browser.browser_type }}'{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
const browserData = [
|
||||||
|
{% for browser in browser_stats %}
|
||||||
|
{{ browser.scan_count }}{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (browserLabels.length === 0 || browserData.length === 0) {
|
||||||
|
console.log('No browser data available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Chart(browserCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: browserLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Scans',
|
||||||
|
data: browserData,
|
||||||
|
backgroundColor: [
|
||||||
|
'#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#8b5cf6', '#06b6d4'
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
|
const percentage = ((context.parsed.x * 100) / total).toFixed(1);
|
||||||
|
return context.label + ': ' + context.parsed.x.toLocaleString() +
|
||||||
|
' (' + percentage + '%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility Functions
|
||||||
|
function refreshStatistics() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh functionality (optional)
|
||||||
|
let autoRefresh = false;
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefresh = !autoRefresh;
|
||||||
|
if (autoRefresh) {
|
||||||
|
setInterval(refreshStatistics, 300000); // Refresh every 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user