Updated attendance report page
This commit is contained in:
@@ -1617,18 +1617,86 @@ def toggle_qr_status_api(qr_id):
|
|||||||
@app.route('/attendance')
|
@app.route('/attendance')
|
||||||
@admin_required
|
@admin_required
|
||||||
def attendance_report():
|
def attendance_report():
|
||||||
"""Attendance report page (Admin only)"""
|
"""Enhanced attendance report page with date range filtering and location data (Admin only)"""
|
||||||
try:
|
try:
|
||||||
# Get filter parameters
|
# Get filter parameters
|
||||||
date_filter = request.args.get('date', '')
|
date_from = request.args.get('date_from', '')
|
||||||
|
date_to = request.args.get('date_to', '')
|
||||||
location_filter = request.args.get('location', '')
|
location_filter = request.args.get('location', '')
|
||||||
employee_filter = request.args.get('employee', '')
|
employee_filter = request.args.get('employee', '')
|
||||||
|
|
||||||
# Base query using the view
|
# Build base query with enhanced location data
|
||||||
query = db.session.execute(text("SELECT * FROM attendance_report WHERE 1=1"))
|
base_query = """
|
||||||
|
SELECT
|
||||||
|
ad.id,
|
||||||
|
ad.employee_id,
|
||||||
|
ad.check_in_date,
|
||||||
|
ad.check_in_time,
|
||||||
|
ad.location_name,
|
||||||
|
qc.location_event,
|
||||||
|
qc.location_address as qr_address,
|
||||||
|
ad.address as checked_in_address,
|
||||||
|
ad.latitude,
|
||||||
|
ad.longitude,
|
||||||
|
ad.accuracy,
|
||||||
|
ad.device_info
|
||||||
|
FROM attendance_data ad
|
||||||
|
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
|
||||||
# Apply filters (you can enhance this with proper SQLAlchemy filtering)
|
conditions = []
|
||||||
attendance_records = query.fetchall()
|
params = {}
|
||||||
|
|
||||||
|
# Apply date range filter
|
||||||
|
if date_from:
|
||||||
|
conditions.append("ad.check_in_date >= :date_from")
|
||||||
|
params['date_from'] = date_from
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
conditions.append("ad.check_in_date <= :date_to")
|
||||||
|
params['date_to'] = date_to
|
||||||
|
|
||||||
|
# Apply location filter
|
||||||
|
if location_filter:
|
||||||
|
conditions.append("ad.location_name ILIKE :location")
|
||||||
|
params['location'] = f"%{location_filter}%"
|
||||||
|
|
||||||
|
# Apply employee filter
|
||||||
|
if employee_filter:
|
||||||
|
conditions.append("ad.employee_id ILIKE :employee")
|
||||||
|
params['employee'] = f"%{employee_filter}%"
|
||||||
|
|
||||||
|
# Add conditions to query
|
||||||
|
if conditions:
|
||||||
|
base_query += " AND " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
# Add ordering
|
||||||
|
base_query += " ORDER BY ad.check_in_date DESC, ad.check_in_time DESC"
|
||||||
|
|
||||||
|
# Execute query
|
||||||
|
query_result = db.session.execute(text(base_query), params)
|
||||||
|
attendance_records = query_result.fetchall()
|
||||||
|
|
||||||
|
# Process records to add calculated fields
|
||||||
|
processed_records = []
|
||||||
|
for record in attendance_records:
|
||||||
|
record_dict = {
|
||||||
|
'id': record.id,
|
||||||
|
'employee_id': record.employee_id,
|
||||||
|
'check_in_date': record.check_in_date,
|
||||||
|
'check_in_time': record.check_in_time,
|
||||||
|
'location_name': record.location_name,
|
||||||
|
'location_event': record.location_event,
|
||||||
|
'qr_address': record.qr_address or 'Not available',
|
||||||
|
'checked_in_address': record.checked_in_address or 'Location not captured',
|
||||||
|
'device_info': record.device_info,
|
||||||
|
'accuracy': record.accuracy,
|
||||||
|
'accuracy_level': get_accuracy_level(record.accuracy),
|
||||||
|
'has_location_data': record.latitude is not None and record.longitude is not None,
|
||||||
|
'coordinates': f"{record.latitude:.6f}, {record.longitude:.6f}" if record.latitude and record.longitude else "No GPS data"
|
||||||
|
}
|
||||||
|
processed_records.append(record_dict)
|
||||||
|
|
||||||
# Get unique locations for filter dropdown
|
# Get unique locations for filter dropdown
|
||||||
locations_query = db.session.execute(text("""
|
locations_query = db.session.execute(text("""
|
||||||
@@ -1644,7 +1712,9 @@ def attendance_report():
|
|||||||
COUNT(*) as total_checkins,
|
COUNT(*) as total_checkins,
|
||||||
COUNT(DISTINCT employee_id) as unique_employees,
|
COUNT(DISTINCT employee_id) as unique_employees,
|
||||||
COUNT(DISTINCT qr_code_id) as active_locations,
|
COUNT(DISTINCT qr_code_id) as active_locations,
|
||||||
COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END) as today_checkins
|
COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END) as today_checkins,
|
||||||
|
COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END) as records_with_gps,
|
||||||
|
AVG(accuracy) as avg_accuracy
|
||||||
FROM attendance_data
|
FROM attendance_data
|
||||||
"""))
|
"""))
|
||||||
stats = stats_query.fetchone()
|
stats = stats_query.fetchone()
|
||||||
@@ -1654,10 +1724,11 @@ def attendance_report():
|
|||||||
current_date_formatted = datetime.now().strftime('%B %d')
|
current_date_formatted = datetime.now().strftime('%B %d')
|
||||||
|
|
||||||
return render_template('attendance_report.html',
|
return render_template('attendance_report.html',
|
||||||
attendance_records=attendance_records,
|
attendance_records=processed_records,
|
||||||
locations=locations,
|
locations=locations,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
date_filter=date_filter,
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
location_filter=location_filter,
|
location_filter=location_filter,
|
||||||
employee_filter=employee_filter,
|
employee_filter=employee_filter,
|
||||||
today_date=today_date,
|
today_date=today_date,
|
||||||
@@ -1665,9 +1736,20 @@ def attendance_report():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading attendance report: {e}")
|
print(f"Error loading attendance report: {e}")
|
||||||
flash('Error loading attendance report.', 'error')
|
flash('Error loading attendance report. Please try again.', 'error')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
def get_accuracy_level(accuracy):
|
||||||
|
"""Get human-readable accuracy level"""
|
||||||
|
if not accuracy:
|
||||||
|
return 'unknown'
|
||||||
|
elif accuracy <= 50:
|
||||||
|
return 'high'
|
||||||
|
elif accuracy <= 100:
|
||||||
|
return 'medium'
|
||||||
|
else:
|
||||||
|
return 'low'
|
||||||
|
|
||||||
@app.route('/api/attendance/stats')
|
@app.route('/api/attendance/stats')
|
||||||
@admin_required
|
@admin_required
|
||||||
def attendance_stats_api():
|
def attendance_stats_api():
|
||||||
|
|||||||
+282
-5
@@ -97,7 +97,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.primary::before {
|
.stat-card.primary::before {
|
||||||
background: linear-gradient(90deg, var(--primary-color), var(--primary-hover));
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--primary-color),
|
||||||
|
var(--primary-hover)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.success::before {
|
.stat-card.success::before {
|
||||||
@@ -130,7 +134,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.primary .stat-icon {
|
.stat-card.primary .stat-icon {
|
||||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--primary-color),
|
||||||
|
var(--primary-hover)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.success .stat-icon {
|
.stat-card.success .stat-icon {
|
||||||
@@ -378,7 +386,7 @@
|
|||||||
.employee-id {
|
.employee-id {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
font-family: 'Courier New', monospace;
|
font-family: "Courier New", monospace;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
background: var(--gray-100);
|
background: var(--gray-100);
|
||||||
padding: var(--spacing-1) var(--spacing-2);
|
padding: var(--spacing-1) var(--spacing-2);
|
||||||
@@ -403,13 +411,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.date-info {
|
.date-info {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: "Courier New", monospace;
|
||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-info {
|
.time-info {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: "Courier New", monospace;
|
||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--primary-light);
|
background: var(--primary-light);
|
||||||
@@ -706,6 +714,275 @@
|
|||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
color: var(--gray-700);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-info i {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-info span {
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-info.qr-address i {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-info.checkin-address i {
|
||||||
|
color: var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS Accuracy badges */
|
||||||
|
.accuracy-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
padding: var(--spacing-1) var(--spacing-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge small {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge.accuracy-high {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success-color);
|
||||||
|
border: 1px solid rgba(5, 150, 105, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge.accuracy-medium {
|
||||||
|
background: var(--warning-light);
|
||||||
|
color: var(--warning-color);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge.accuracy-low {
|
||||||
|
background: var(--danger-light);
|
||||||
|
color: var(--danger-color);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge.accuracy-unknown {
|
||||||
|
background: var(--gray-100);
|
||||||
|
color: var(--gray-500);
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter indicator */
|
||||||
|
.filter-indicator {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: var(--spacing-1) var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-left: var(--spacing-2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced action buttons */
|
||||||
|
.btn-map {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-map:hover {
|
||||||
|
background: var(--success-color);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-map:disabled {
|
||||||
|
background: var(--gray-100);
|
||||||
|
color: var(--gray-400);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal enhancements */
|
||||||
|
.modal-large .modal-content {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: var(--spacing-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
background: var(--gray-50);
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
color: var(--gray-800);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--spacing-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 i {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: var(--spacing-2) 0;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item strong {
|
||||||
|
color: var(--gray-700);
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item span {
|
||||||
|
color: var(--gray-900);
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced date range filters */
|
||||||
|
.filter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group input[type="date"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'%3e%3cpath fill-rule='evenodd' d='M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z' clip-rule='evenodd'/%3e%3c/svg%3e");
|
||||||
|
background-size: 16px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced table responsive design */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.attendance-table th:nth-child(7),
|
||||||
|
.attendance-table td:nth-child(7),
|
||||||
|
.attendance-table th:nth-child(8),
|
||||||
|
.attendance-table td:nth-child(8) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.address-info {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge {
|
||||||
|
min-width: 60px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-details-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide address columns on mobile */
|
||||||
|
.attendance-table th:nth-child(7),
|
||||||
|
.attendance-table td:nth-child(7),
|
||||||
|
.attendance-table th:nth-child(8),
|
||||||
|
.attendance-table td:nth-child(8),
|
||||||
|
.attendance-table th:nth-child(9),
|
||||||
|
.attendance-table td:nth-child(9) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced empty state */
|
||||||
|
.empty-state .btn {
|
||||||
|
margin-top: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location map placeholder styles */
|
||||||
|
#locationMap {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 2px dashed var(--gray-300);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print styles for new columns */
|
||||||
|
@media print {
|
||||||
|
.address-info,
|
||||||
|
.accuracy-info {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy-badge {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-map {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.attendance-page {
|
.attendance-page {
|
||||||
|
|||||||
+339
-503
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@
|
|||||||
<i class="fas fa-chart-line"></i>
|
<i class="fas fa-chart-line"></i>
|
||||||
Attendance Report
|
Attendance Report
|
||||||
</h1>
|
</h1>
|
||||||
<p>Monitor and analyze staff attendance across all locations</p>
|
<p>Monitor and analyze staff attendance with location accuracy tracking</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Enhanced Statistics Cards -->
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card primary">
|
<div class="stat-card primary">
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<h3>{{ stats.active_locations or 0 }}</h3>
|
<h3>{{ stats.active_locations or 0 }}</h3>
|
||||||
<p>Active Locations</p>
|
<p>Active Locations</p>
|
||||||
<span class="stat-trend">With check-ins</span>
|
<span class="stat-trend">QR codes deployed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,46 +76,74 @@
|
|||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<h3>{{ stats.today_checkins or 0 }}</h3>
|
<h3>{{ stats.today_checkins or 0 }}</h3>
|
||||||
<p>Today's Check-ins</p>
|
<p>Today's Check-ins</p>
|
||||||
<span class="stat-trend">{{ current_date_formatted or 'Today' }}</span>
|
<span class="stat-trend">{{ current_date_formatted }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New GPS accuracy stats -->
|
||||||
|
<div class="stat-card info">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-satellite"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ stats.records_with_gps or 0 }}</h3>
|
||||||
|
<p>GPS Records</p>
|
||||||
|
<span class="stat-trend">Location captured</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card success">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-crosshairs"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ "%.1f"|format(stats.avg_accuracy or 0) }}m</h3>
|
||||||
|
<p>Avg. Accuracy</p>
|
||||||
|
<span class="stat-trend">Location precision</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters Section -->
|
<!-- Enhanced Filters Section with Date Range -->
|
||||||
<div class="filters-section">
|
<div class="filters-section">
|
||||||
<div class="filters-card">
|
<div class="filters-container">
|
||||||
<div class="filters-header">
|
<form method="GET" action="{{ url_for('attendance_report') }}">
|
||||||
<h3>
|
|
||||||
<i class="fas fa-filter"></i>
|
|
||||||
Filter Records
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="GET" class="filters-form" id="filtersForm">
|
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
|
<!-- Date Range Filters -->
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="date">
|
<label for="date_from">
|
||||||
<i class="fas fa-calendar"></i>
|
<i class="fas fa-calendar-alt"></i>
|
||||||
Date
|
From Date
|
||||||
</label>
|
</label>
|
||||||
<input type="date"
|
<input type="date"
|
||||||
id="date"
|
id="date_from"
|
||||||
name="date"
|
name="date_from"
|
||||||
value="{{ date_filter }}"
|
value="{{ date_from }}"
|
||||||
max="{{ today_date or '' }}">
|
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>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="location">
|
<label for="location">
|
||||||
<i class="fas fa-building"></i>
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
Location
|
Location
|
||||||
</label>
|
</label>
|
||||||
<select id="location" name="location">
|
<select id="location" name="location">
|
||||||
<option value="">All Locations</option>
|
<option value="">All Locations</option>
|
||||||
{% for location in locations %}
|
{% for location in locations %}
|
||||||
<option value="{{ location }}"
|
<option value="{{ location }}" {{ 'selected' if location == location_filter else '' }}>
|
||||||
{{ 'selected' if location == location_filter else '' }}>
|
|
||||||
{{ location }}
|
{{ location }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -149,12 +177,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attendance Table -->
|
<!-- Enhanced Attendance Table -->
|
||||||
<div class="attendance-table-section">
|
<div class="attendance-table-section">
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<h3>
|
<h3>
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
Attendance Records
|
Attendance Records
|
||||||
|
{% if date_from or date_to or location_filter or employee_filter %}
|
||||||
|
<span class="filter-indicator">(Filtered)</span>
|
||||||
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="table-controls">
|
<div class="table-controls">
|
||||||
<div class="entries-per-page">
|
<div class="entries-per-page">
|
||||||
@@ -181,8 +212,10 @@
|
|||||||
<th onclick="sortTable(3)">Event <i class="fas fa-sort"></i></th>
|
<th onclick="sortTable(3)">Event <i class="fas fa-sort"></i></th>
|
||||||
<th onclick="sortTable(4)">Date <i class="fas fa-sort"></i></th>
|
<th onclick="sortTable(4)">Date <i class="fas fa-sort"></i></th>
|
||||||
<th onclick="sortTable(5)">Time <i class="fas fa-sort"></i></th>
|
<th onclick="sortTable(5)">Time <i class="fas fa-sort"></i></th>
|
||||||
<th onclick="sortTable(6)">Device <i class="fas fa-sort"></i></th>
|
<th onclick="sortTable(6)">QR Address <i class="fas fa-sort"></i></th>
|
||||||
<th onclick="sortTable(7)">Status <i class="fas fa-sort"></i></th>
|
<th onclick="sortTable(7)">Check-in Address <i class="fas fa-sort"></i></th>
|
||||||
|
<th onclick="sortTable(8)">GPS Accuracy <i class="fas fa-sort"></i></th>
|
||||||
|
<th onclick="sortTable(9)">Device <i class="fas fa-sort"></i></th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -216,6 +249,39 @@
|
|||||||
{{ record.check_in_time.strftime('%H:%M') }}
|
{{ record.check_in_time.strftime('%H:%M') }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="address-info qr-address">
|
||||||
|
<i class="fas fa-qrcode"></i>
|
||||||
|
<span title="{{ record.qr_address }}">
|
||||||
|
{{ record.qr_address[:50] }}{% if record.qr_address|length > 50 %}...{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="address-info checkin-address">
|
||||||
|
<i class="fas fa-location-arrow"></i>
|
||||||
|
<span title="{{ record.checked_in_address }}">
|
||||||
|
{{ record.checked_in_address[:50] }}{% if record.checked_in_address|length > 50 %}...{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="accuracy-info">
|
||||||
|
{% if record.accuracy %}
|
||||||
|
<span class="accuracy-badge accuracy-{{ record.accuracy_level }}"
|
||||||
|
title="GPS accuracy: {{ record.accuracy }}m">
|
||||||
|
<i class="fas fa-crosshairs"></i>
|
||||||
|
{{ "%.1f"|format(record.accuracy) }}m
|
||||||
|
<small>({{ record.accuracy_level }})</small>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="accuracy-badge accuracy-unknown" title="No GPS data available">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
No GPS
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="device-info">
|
<div class="device-info">
|
||||||
<i class="fas fa-mobile-alt"></i>
|
<i class="fas fa-mobile-alt"></i>
|
||||||
@@ -224,29 +290,24 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="status-badge {{ record.status }}">
|
|
||||||
<i class="fas {{ 'fa-check-circle' if record.status == 'present' else 'fa-times-circle' }}"></i>
|
|
||||||
{{ record.status.title() }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="record-actions">
|
<div class="record-actions">
|
||||||
<button onclick="viewRecordDetails({{ record.id }})"
|
<button onclick="viewRecordDetails('{{ record.id }}')"
|
||||||
class="action-btn btn-view"
|
class="action-btn btn-view"
|
||||||
title="View Details">
|
title="View Details">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="editRecord({{ record.id }})"
|
<button onclick="showLocationMap('{{ record.id }}')"
|
||||||
|
class="action-btn btn-map"
|
||||||
|
title="Show on Map"
|
||||||
|
{% if not record.has_location_data %}disabled{% endif %}>
|
||||||
|
<i class="fas fa-map"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="editRecord('{{ record.id }}')"
|
||||||
class="action-btn btn-edit"
|
class="action-btn btn-edit"
|
||||||
title="Edit Record">
|
title="Edit Record">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteRecord({{ record.id }})"
|
|
||||||
class="action-btn btn-delete"
|
|
||||||
title="Delete Record">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -259,76 +320,199 @@
|
|||||||
<i class="fas fa-clipboard-list"></i>
|
<i class="fas fa-clipboard-list"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3>No Attendance Records Found</h3>
|
<h3>No Attendance Records Found</h3>
|
||||||
<p>{% if date_filter or location_filter or employee_filter %}
|
<p>{% if date_from or date_to or location_filter or employee_filter %}
|
||||||
No records match your current filters. Try adjusting the filter criteria.
|
No records match your current filters. Try adjusting the filter criteria.
|
||||||
{% else %}
|
{% else %}
|
||||||
No staff have checked in yet. QR codes need to be scanned to generate attendance data.
|
No staff have checked in yet. QR codes need to be scanned to generate attendance data.
|
||||||
{% endif %}</p>
|
{% endif %}</p>
|
||||||
|
{% if date_from or date_to or location_filter or employee_filter %}
|
||||||
<button onclick="clearFilters()" class="btn btn-primary">
|
<button onclick="clearFilters()" class="btn btn-primary">
|
||||||
<i class="fas fa-refresh"></i>
|
<i class="fas fa-times"></i>
|
||||||
Clear Filters
|
Clear All Filters
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
|
{% if attendance_records %}
|
||||||
<div class="pagination-container" id="paginationContainer">
|
<div class="pagination-container" id="paginationContainer">
|
||||||
<!-- Pagination will be dynamically generated -->
|
<!-- Pagination will be generated by JavaScript -->
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Charts Section -->
|
|
||||||
<div class="charts-section">
|
|
||||||
<div class="charts-grid">
|
|
||||||
<div class="chart-card">
|
|
||||||
<div class="chart-header">
|
|
||||||
<h3>
|
|
||||||
<i class="fas fa-chart-bar"></i>
|
|
||||||
Daily Check-ins (Last 7 Days)
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="dailyChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chart-card">
|
|
||||||
<div class="chart-header">
|
|
||||||
<h3>
|
|
||||||
<i class="fas fa-chart-pie"></i>
|
|
||||||
Check-ins by Location
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="locationChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Record Details Modal -->
|
<!-- Enhanced Record Details Modal -->
|
||||||
<div id="recordModal" class="modal">
|
<div id="recordModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="modalTitle">Attendance Record Details</h3>
|
<h3 id="modalTitle">Record Details</h3>
|
||||||
<button class="modal-close" onclick="closeRecordModal()">
|
<button onclick="closeModal()" class="modal-close">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" id="modalBody">
|
<div class="modal-body" id="modalBody">
|
||||||
<!-- Dynamic content will be loaded here -->
|
<!-- Content will be populated by JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button onclick="closeRecordModal()" class="btn btn-secondary">
|
<button onclick="closeModal()" class="btn btn-secondary">Close</button>
|
||||||
Close
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Modal for Location Viewing -->
|
||||||
|
<div id="mapModal" class="modal">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="mapModalTitle">Location Map</h3>
|
||||||
|
<button onclick="closeMapModal()" class="modal-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-body" id="mapModalBody">
|
||||||
|
<div id="locationMap" style="height: 400px; width: 100%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button onclick="closeMapModal()" class="btn btn-secondary">Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/attendance_report.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/attendance_report.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// Enhanced JavaScript for new functionality
|
||||||
|
function clearFilters() {
|
||||||
|
window.location.href = "{{ url_for('attendance_report') }}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportAttendance() {
|
||||||
|
// Build export URL with current filters
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if ('{{ date_from }}') params.append('date_from', '{{ date_from }}');
|
||||||
|
if ('{{ date_to }}') params.append('date_to', '{{ date_to }}');
|
||||||
|
if ('{{ location_filter }}') params.append('location', '{{ location_filter }}');
|
||||||
|
if ('{{ employee_filter }}') params.append('employee', '{{ employee_filter }}');
|
||||||
|
params.append('export', 'csv');
|
||||||
|
|
||||||
|
window.open(`{{ url_for('attendance_report') }}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshReport() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLocationMap(recordId) {
|
||||||
|
// Find the record data
|
||||||
|
const record = attendanceData.find(r => r.id == recordId);
|
||||||
|
if (!record || !record.has_location_data) {
|
||||||
|
alert('No GPS data available for this record');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show map modal (requires Google Maps API or similar)
|
||||||
|
document.getElementById('mapModal').style.display = 'block';
|
||||||
|
document.getElementById('mapModalTitle').textContent = `Location - ${record.employeeId}`;
|
||||||
|
|
||||||
|
// Initialize map (placeholder - requires actual map implementation)
|
||||||
|
const mapContainer = document.getElementById('locationMap');
|
||||||
|
mapContainer.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 100px;">
|
||||||
|
<i class="fas fa-map-marked-alt" style="font-size: 3rem; color: #6b7280; margin-bottom: 1rem;"></i>
|
||||||
|
<p><strong>GPS Coordinates:</strong> ${record.coordinates}</p>
|
||||||
|
<p><strong>Accuracy:</strong> ${record.accuracy}m (${record.accuracy_level})</p>
|
||||||
|
<p><em>Map integration requires Google Maps API setup</em></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMapModal() {
|
||||||
|
document.getElementById('mapModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced record details view
|
||||||
|
function viewRecordDetails(recordId) {
|
||||||
|
const record = attendanceData.find(r => r.id == recordId);
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
const modal = document.getElementById('recordModal');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalBody = document.getElementById('modalBody');
|
||||||
|
|
||||||
|
if (!modal || !modalTitle || !modalBody) return;
|
||||||
|
|
||||||
|
modalTitle.textContent = `Attendance Record - ${record.employeeId}`;
|
||||||
|
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="record-details-grid">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4><i class="fas fa-user"></i> Employee Information</h4>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Employee ID:</strong>
|
||||||
|
<span>${record.employeeId}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Check-in Date:</strong>
|
||||||
|
<span>${record.date}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Check-in Time:</strong>
|
||||||
|
<span>${record.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4><i class="fas fa-map-marker-alt"></i> Location Information</h4>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Location Name:</strong>
|
||||||
|
<span>${record.location}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Event:</strong>
|
||||||
|
<span>${record.event}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>QR Code Address:</strong>
|
||||||
|
<span>${record.qr_address}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Check-in Address:</strong>
|
||||||
|
<span>${record.checked_in_address}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4><i class="fas fa-satellite"></i> GPS Information</h4>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Coordinates:</strong>
|
||||||
|
<span>${record.coordinates}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Accuracy:</strong>
|
||||||
|
<span>${record.accuracy ? record.accuracy + 'm (' + record.accuracy_level + ')' : 'No GPS data'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Location Status:</strong>
|
||||||
|
<span class="status-badge ${record.has_location_data ? 'success' : 'warning'}">
|
||||||
|
${record.has_location_data ? 'GPS Available' : 'No GPS Data'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4><i class="fas fa-mobile-alt"></i> Device Information</h4>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Device:</strong>
|
||||||
|
<span>${record.device}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user