633 lines
23 KiB
HTML
633 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Attendance Report{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/attendance.css') }}">
|
|
<style>
|
|
/* Additional styles for location data */
|
|
.location-data {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 11px;
|
|
color: #495057;
|
|
background: #f8f9fa;
|
|
padding: 4px 6px;
|
|
border-radius: 4px;
|
|
margin: 2px 0;
|
|
}
|
|
|
|
.coordinates {
|
|
display: block;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.accuracy-badge {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
border-radius: 10px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.accuracy-high {
|
|
background: #d4f4dd;
|
|
color: #166534;
|
|
}
|
|
|
|
.accuracy-medium {
|
|
background: #fef3c7;
|
|
color: #92400e;
|
|
}
|
|
|
|
.accuracy-low {
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
}
|
|
|
|
.location-indicator {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 2px 6px;
|
|
border-radius: 10px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.has-gps {
|
|
background: #d4f4dd;
|
|
color: #166534;
|
|
}
|
|
|
|
.no-gps {
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
}
|
|
|
|
.address-info {
|
|
font-size: 11px;
|
|
color: #6c757d;
|
|
max-width: 150px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.map-link {
|
|
color: #2563eb;
|
|
text-decoration: none;
|
|
font-size: 10px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.map-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.location-stats {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
}
|
|
|
|
.location-stats .stat-icon {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
color: white;
|
|
}
|
|
|
|
/* Mobile responsiveness for location columns */
|
|
@media (max-width: 768px) {
|
|
.location-data {
|
|
font-size: 9px;
|
|
padding: 2px 4px;
|
|
}
|
|
|
|
.coordinates {
|
|
display: none; /* Hide coordinates on mobile */
|
|
}
|
|
|
|
.address-info {
|
|
max-width: 80px;
|
|
font-size: 9px;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="attendance-page">
|
|
<!-- Header Section -->
|
|
<div class="attendance-header">
|
|
<div class="header-content">
|
|
<h1>
|
|
<i class="fas fa-chart-line"></i>
|
|
Attendance Report
|
|
</h1>
|
|
<p>Comprehensive attendance tracking with location data for {{ current_date_formatted }}</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button onclick="exportToCSV()" class="btn btn-outline">
|
|
<i class="fas fa-download"></i>
|
|
Export CSV
|
|
</button>
|
|
<button onclick="printReport()" class="btn btn-outline">
|
|
<i class="fas fa-print"></i>
|
|
Print Report
|
|
</button>
|
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
|
|
<i class="fas fa-arrow-left"></i>
|
|
Back to Dashboard
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics Section -->
|
|
<div class="stats-section">
|
|
<div class="stats-grid">
|
|
<div class="stat-card primary">
|
|
<div class="stat-icon">
|
|
<i class="fas fa-users"></i>
|
|
</div>
|
|
<div class="stat-content">
|
|
<h3>{{ stats.total_checkins }}</h3>
|
|
<p>Total Check-ins</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat-card success">
|
|
<div class="stat-icon">
|
|
<i class="fas fa-user-check"></i>
|
|
</div>
|
|
<div class="stat-content">
|
|
<h3>{{ stats.unique_employees }}</h3>
|
|
<p>Unique Employees</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat-card info">
|
|
<div class="stat-icon">
|
|
<i class="fas fa-calendar-day"></i>
|
|
</div>
|
|
<div class="stat-content">
|
|
<h3>{{ stats.today_checkins }}</h3>
|
|
<p>Today's Check-ins</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- NEW: Location Statistics Card -->
|
|
<div class="stat-card location-stats">
|
|
<div class="stat-icon">
|
|
<i class="fas fa-map-marker-alt"></i>
|
|
</div>
|
|
<div class="stat-content">
|
|
<h3>{{ stats.checkins_with_location }}</h3>
|
|
<p>With GPS Location</p>
|
|
<small>{{ location_coverage }}% coverage</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters Section -->
|
|
<div class="filters-section">
|
|
<div class="filters-card">
|
|
<div class="filters-header">
|
|
<h3>
|
|
<i class="fas fa-filter"></i>
|
|
Filter Records
|
|
</h3>
|
|
</div>
|
|
|
|
<form method="GET" action="{{ url_for('attendance_report') }}">
|
|
<div class="filter-row">
|
|
<div class="filter-group">
|
|
<label for="date">
|
|
<i class="fas fa-calendar"></i>
|
|
Date
|
|
</label>
|
|
<input type="date"
|
|
id="date"
|
|
name="date"
|
|
value="{{ date_filter }}"
|
|
max="{{ today_date }}">
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label for="location">
|
|
<i class="fas fa-map-marker-alt"></i>
|
|
Location
|
|
</label>
|
|
<select id="location" name="location">
|
|
<option value="">All Locations</option>
|
|
{% for location in locations %}
|
|
<option value="{{ location }}"
|
|
{% if location == location_filter %}selected{% endif %}>
|
|
{{ location }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label for="employee">
|
|
<i class="fas fa-id-badge"></i>
|
|
Employee ID
|
|
</label>
|
|
<input type="text"
|
|
id="employee"
|
|
name="employee"
|
|
value="{{ employee_filter }}"
|
|
placeholder="Enter Employee ID">
|
|
</div>
|
|
|
|
<div class="filter-actions">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="fas fa-search"></i>
|
|
Apply Filters
|
|
</button>
|
|
<button type="button" onclick="clearFilters()" class="btn btn-outline">
|
|
<i class="fas fa-times"></i>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Attendance Table -->
|
|
<div class="attendance-table-section">
|
|
<div class="table-header">
|
|
<h3>
|
|
<i class="fas fa-list"></i>
|
|
Attendance Records
|
|
{% if attendance_records %}
|
|
<span class="record-count">({{ attendance_records|length }} records)</span>
|
|
{% endif %}
|
|
</h3>
|
|
<div class="table-controls">
|
|
<div class="entries-per-page">
|
|
<label>Show:</label>
|
|
<select id="entriesPerPage" onchange="changeEntriesPerPage()">
|
|
<option value="25">25</option>
|
|
<option value="50" selected>50</option>
|
|
<option value="100">100</option>
|
|
<option value="all">All</option>
|
|
</select>
|
|
<span>entries</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
{% if attendance_records %}
|
|
<table class="attendance-table" id="attendanceTable">
|
|
<thead>
|
|
<tr>
|
|
<th onclick="sortTable(0)">#</th>
|
|
<th onclick="sortTable(1)">Employee ID <i class="fas fa-sort"></i></th>
|
|
<th onclick="sortTable(2)">Location <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(5)">Time <i class="fas fa-sort"></i></th>
|
|
<!-- NEW: Location Data Columns -->
|
|
<th onclick="sortTable(6)">GPS Status <i class="fas fa-sort"></i></th>
|
|
<th onclick="sortTable(7)">Coordinates <i class="fas fa-sort"></i></th>
|
|
<th onclick="sortTable(8)">Accuracy <i class="fas fa-sort"></i></th>
|
|
<th onclick="sortTable(9)">Address <i class="fas fa-sort"></i></th>
|
|
<th onclick="sortTable(10)">Device <i class="fas fa-sort"></i></th>
|
|
<th onclick="sortTable(11)">Status <i class="fas fa-sort"></i></th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for record in attendance_records %}
|
|
<tr data-record-id="{{ record.id }}">
|
|
<td>{{ loop.index }}</td>
|
|
<td>
|
|
<div class="employee-info">
|
|
<span class="employee-id">{{ record.employee_id }}</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="location-info">
|
|
<i class="fas fa-map-marker-alt"></i>
|
|
{{ record.location_name }}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="event-info">
|
|
{{ record.location_event }}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="date-info">
|
|
{{ record.check_in_date.strftime('%Y-%m-%d') }}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="time-info">
|
|
{{ record.check_in_time.strftime('%H:%M') }}
|
|
</div>
|
|
</td>
|
|
|
|
<!-- NEW: GPS Status Column -->
|
|
<td>
|
|
{% if record.latitude and record.longitude %}
|
|
<span class="location-indicator has-gps">
|
|
<i class="fas fa-satellite"></i>
|
|
GPS
|
|
</span>
|
|
{% else %}
|
|
<span class="location-indicator no-gps">
|
|
<i class="fas fa-times"></i>
|
|
No GPS
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<!-- NEW: Coordinates Column -->
|
|
<td>
|
|
{% if record.latitude and record.longitude %}
|
|
<div class="location-data">
|
|
<span class="coordinates">
|
|
{{ "%.6f"|format(record.latitude) }}<br>
|
|
{{ "%.6f"|format(record.longitude) }}
|
|
</span>
|
|
</div>
|
|
{% else %}
|
|
<span style="color: #6c757d; font-style: italic;">Not available</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<!-- NEW: Accuracy Column -->
|
|
<td>
|
|
{% if record.location_accuracy %}
|
|
{% set accuracy_class = 'high' if record.location_accuracy <= 50 else ('medium' if record.location_accuracy <= 100 else 'low') %}
|
|
<span class="accuracy-badge accuracy-{{ accuracy_class }}">
|
|
±{{ "%.0f"|format(record.location_accuracy) }}m
|
|
</span>
|
|
{% else %}
|
|
<span style="color: #6c757d;">-</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<!-- NEW: Address Column -->
|
|
<td>
|
|
{% if record.address %}
|
|
<div class="address-info" title="{{ record.address }}">
|
|
{{ record.address }}
|
|
</div>
|
|
{% else %}
|
|
<span style="color: #6c757d; font-style: italic;">Not resolved</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<td>
|
|
<div class="device-info" title="{{ record.user_agent }}">
|
|
<i class="fas fa-{{ 'mobile-alt' if 'Mobile' in record.device_info else 'desktop' }}"></i>
|
|
{{ record.device_info }}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="status-badge {{ record.status }}">
|
|
<i class="fas fa-{{ 'check' if record.status == 'present' else 'times' }}"></i>
|
|
{{ record.status.title() }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div class="record-actions">
|
|
<button onclick="viewRecord('{{ record.id }}')"
|
|
class="action-btn"
|
|
title="View Details">
|
|
<i class="fas fa-eye"></i>
|
|
</button>
|
|
{% if record.latitude and record.longitude %}
|
|
<a href="https://www.google.com/maps?q={{ record.latitude }},{{ record.longitude }}"
|
|
target="_blank"
|
|
class="map-link"
|
|
title="View on Map">
|
|
<i class="fas fa-external-link-alt"></i>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<i class="fas fa-search"></i>
|
|
<h3>No Records Found</h3>
|
|
<p>{% if date_filter or location_filter or employee_filter %}
|
|
No attendance records match your current filters.
|
|
{% else %}
|
|
No attendance records available yet.
|
|
{% endif %}</p>
|
|
{% if date_filter or location_filter or employee_filter %}
|
|
<button onclick="clearFilters()" class="btn btn-primary">
|
|
<i class="fas fa-times"></i>
|
|
Clear Filters
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Record Details Modal -->
|
|
<div id="recordModal" class="modal" style="display: none;">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>
|
|
<i class="fas fa-info-circle"></i>
|
|
Record Details
|
|
</h3>
|
|
<button onclick="closeModal()" class="modal-close">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body" id="recordDetails">
|
|
<!-- Record details will be loaded here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script src="{{ url_for('static', filename='js/attendance_report.js') }}"></script>
|
|
<script>
|
|
// Additional JavaScript for location features
|
|
|
|
function clearFilters() {
|
|
window.location.href = "{{ url_for('attendance_report') }}";
|
|
}
|
|
|
|
function exportToCSV() {
|
|
// Enhanced CSV export with location data
|
|
const table = document.getElementById('attendanceTable');
|
|
if (!table) return;
|
|
|
|
let csv = [];
|
|
const rows = table.querySelectorAll('tr');
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = [], cols = rows[i].querySelectorAll('td, th');
|
|
|
|
for (let j = 0; j < cols.length - 1; j++) { // Skip actions column
|
|
let cellText = cols[j].innerText.replace(/"/g, '""');
|
|
row.push('"' + cellText + '"');
|
|
}
|
|
csv.push(row.join(','));
|
|
}
|
|
|
|
// Download CSV
|
|
const csvFile = new Blob([csv.join('\n')], { type: 'text/csv' });
|
|
const downloadLink = document.createElement('a');
|
|
downloadLink.download = `attendance_report_${new Date().toISOString().slice(0, 10)}.csv`;
|
|
downloadLink.href = window.URL.createObjectURL(csvFile);
|
|
downloadLink.style.display = 'none';
|
|
document.body.appendChild(downloadLink);
|
|
downloadLink.click();
|
|
document.body.removeChild(downloadLink);
|
|
}
|
|
|
|
function printReport() {
|
|
window.print();
|
|
}
|
|
|
|
function viewRecord(recordId) {
|
|
// Show record details in modal
|
|
const modal = document.getElementById('recordModal');
|
|
const detailsDiv = document.getElementById('recordDetails');
|
|
|
|
// Find the record row
|
|
const row = document.querySelector(`tr[data-record-id="${recordId}"]`);
|
|
if (!row) return;
|
|
|
|
const cells = row.querySelectorAll('td');
|
|
|
|
detailsDiv.innerHTML = `
|
|
<div class="record-detail-grid">
|
|
<div class="detail-item">
|
|
<strong>Employee ID:</strong>
|
|
<span>${cells[1].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Location:</strong>
|
|
<span>${cells[2].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Event:</strong>
|
|
<span>${cells[3].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Date:</strong>
|
|
<span>${cells[4].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Time:</strong>
|
|
<span>${cells[5].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>GPS Status:</strong>
|
|
<span>${cells[6].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Coordinates:</strong>
|
|
<span>${cells[7].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Accuracy:</strong>
|
|
<span>${cells[8].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Address:</strong>
|
|
<span>${cells[9].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Device:</strong>
|
|
<span>${cells[10].textContent.trim()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<strong>Status:</strong>
|
|
<span>${cells[11].textContent.trim()}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('recordModal').style.display = 'none';
|
|
}
|
|
|
|
// Close modal when clicking outside
|
|
window.onclick = function(event) {
|
|
const modal = document.getElementById('recordModal');
|
|
if (event.target === modal) {
|
|
modal.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
console.log('📍 Enhanced attendance report with location data loaded');
|
|
console.log('📊 Location coverage: {{ location_coverage }}%');
|
|
console.log('📍 Records with GPS: {{ stats.checkins_with_location }} / {{ stats.total_checkins }}');
|
|
</script>
|
|
|
|
<style>
|
|
/* Additional styles for modal and details */
|
|
.record-detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 15px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.detail-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.detail-item strong {
|
|
color: #495057;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.detail-item span {
|
|
color: #212529;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.record-count {
|
|
font-size: 14px;
|
|
color: #6c757d;
|
|
font-weight: normal;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.record-detail-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
/* Hide some columns on mobile for better readability */
|
|
.attendance-table th:nth-child(8),
|
|
.attendance-table td:nth-child(8),
|
|
.attendance-table th:nth-child(10),
|
|
.attendance-table td:nth-child(10) {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %} |