From fc8326be92b9be4c8fe2af09795b59978ab48352 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 2 Aug 2025 12:56:46 -0400 Subject: [PATCH] Updated attendance report page --- app.py | 102 ++- static/css/attendance.css | 289 +++++++- static/js/attendance_report.js | 1114 +++++++++++++----------------- templates/attendance_report.html | 342 ++++++--- 4 files changed, 1113 insertions(+), 734 deletions(-) diff --git a/app.py b/app.py index 78f76cc..4d381f7 100644 --- a/app.py +++ b/app.py @@ -1617,18 +1617,86 @@ def toggle_qr_status_api(qr_id): @app.route('/attendance') @admin_required def attendance_report(): - """Attendance report page (Admin only)""" + """Enhanced attendance report page with date range filtering and location data (Admin only)""" try: # 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', '') employee_filter = request.args.get('employee', '') - # Base query using the view - query = db.session.execute(text("SELECT * FROM attendance_report WHERE 1=1")) + # Build base query with enhanced location data + 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) - attendance_records = query.fetchall() + conditions = [] + 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 locations_query = db.session.execute(text(""" @@ -1644,7 +1712,9 @@ def attendance_report(): COUNT(*) as total_checkins, COUNT(DISTINCT employee_id) as unique_employees, 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 """)) stats = stats_query.fetchone() @@ -1654,10 +1724,11 @@ def attendance_report(): current_date_formatted = datetime.now().strftime('%B %d') return render_template('attendance_report.html', - attendance_records=attendance_records, + attendance_records=processed_records, locations=locations, stats=stats, - date_filter=date_filter, + date_from=date_from, + date_to=date_to, location_filter=location_filter, employee_filter=employee_filter, today_date=today_date, @@ -1665,8 +1736,19 @@ def attendance_report(): except Exception as 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')) + +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') @admin_required diff --git a/static/css/attendance.css b/static/css/attendance.css index db4447d..c076d73 100644 --- a/static/css/attendance.css +++ b/static/css/attendance.css @@ -97,7 +97,11 @@ } .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 { @@ -130,7 +134,11 @@ } .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 { @@ -378,7 +386,7 @@ .employee-id { font-weight: 600; color: var(--gray-900); - font-family: 'Courier New', monospace; + font-family: "Courier New", monospace; font-size: var(--font-size-sm); background: var(--gray-100); padding: var(--spacing-1) var(--spacing-2); @@ -403,13 +411,13 @@ } .date-info { - font-family: 'Courier New', monospace; + font-family: "Courier New", monospace; color: var(--gray-900); font-weight: 500; } .time-info { - font-family: 'Courier New', monospace; + font-family: "Courier New", monospace; color: var(--gray-900); font-weight: 600; background: var(--primary-light); @@ -706,6 +714,275 @@ 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 */ @media (max-width: 768px) { .attendance-page { @@ -818,4 +1095,4 @@ .pagination-container { display: none; } -} \ No newline at end of file +} diff --git a/static/js/attendance_report.js b/static/js/attendance_report.js index 6f7ccf3..17a7e56 100644 --- a/static/js/attendance_report.js +++ b/static/js/attendance_report.js @@ -1,13 +1,13 @@ /** - * Attendance Report JavaScript - * Handles filtering, sorting, pagination, and analytics for attendance data + * Enhanced Attendance Report JavaScript + * Handles filtering, sorting, pagination, and new location/accuracy features */ // Global variables let currentPage = 1; let entriesPerPage = 50; let sortColumn = -1; -let sortDirection = 'asc'; +let sortDirection = "asc"; let attendanceData = []; let filteredData = []; @@ -16,708 +16,544 @@ let dailyChart = null; let locationChart = null; // Initialize page when DOM is loaded -document.addEventListener('DOMContentLoaded', function() { - console.log('Attendance Report page initialized'); - - initializeReport(); - loadAttendanceData(); - initializeCharts(); - setupEventListeners(); +document.addEventListener("DOMContentLoaded", function () { + console.log("Enhanced Attendance Report page initialized"); + + initializeReport(); + loadAttendanceData(); + initializeCharts(); + setupEventListeners(); + initializeDateRangeFilters(); }); function initializeReport() { - // Load data from table - loadTableData(); - - // Initialize pagination - updatePagination(); - - // Apply initial filters if any - applyFilters(); + // Load data from table + loadTableData(); + + // Initialize pagination + updatePagination(); + + // Apply initial filters if any + applyFilters(); } function loadTableData() { - const table = document.getElementById('attendanceTable'); - if (table) { - const rows = table.querySelectorAll('tbody tr'); - attendanceData = Array.from(rows).map((row, index) => { - const cells = row.querySelectorAll('td'); - return { - id: row.dataset.recordId, - index: index + 1, - employeeId: cells[1] ? cells[1].textContent.trim() : '', - location: cells[2] ? cells[2].textContent.trim() : '', - event: cells[3] ? cells[3].textContent.trim() : '', - date: cells[4] ? cells[4].textContent.trim() : '', - time: cells[5] ? cells[5].textContent.trim() : '', - device: cells[6] ? cells[6].getAttribute('title') || cells[6].textContent.trim() : '', - status: cells[7] ? cells[7].textContent.trim() : '', - element: row - }; - }); - - filteredData = [...attendanceData]; - } + const table = document.getElementById("attendanceTable"); + if (table) { + const rows = table.querySelectorAll("tbody tr"); + attendanceData = Array.from(rows).map((row, index) => { + const cells = row.querySelectorAll("td"); + return { + id: row.dataset.recordId, + index: index + 1, + employeeId: cells[1] ? cells[1].textContent.trim() : "", + location: cells[2] ? cells[2].textContent.trim() : "", + event: cells[3] ? cells[3].textContent.trim() : "", + date: cells[4] ? cells[4].textContent.trim() : "", + time: cells[5] ? cells[5].textContent.trim() : "", + qr_address: cells[6] + ? cells[6].getAttribute("title") || cells[6].textContent.trim() + : "", + checked_in_address: cells[7] + ? cells[7].getAttribute("title") || cells[7].textContent.trim() + : "", + accuracy: cells[8] ? extractAccuracyValue(cells[8]) : null, + accuracy_level: cells[8] ? extractAccuracyLevel(cells[8]) : "unknown", + device: cells[9] + ? cells[9].getAttribute("title") || cells[9].textContent.trim() + : "", + has_location_data: cells[8] + ? !cells[8].textContent.includes("No GPS") + : false, + coordinates: extractCoordinates(cells[8]), + }; + }); + + filteredData = [...attendanceData]; + console.log(`Loaded ${attendanceData.length} attendance records`); + } +} + +function extractAccuracyValue(cell) { + const text = cell.textContent; + const match = text.match(/(\d+\.?\d*)m/); + return match ? parseFloat(match[1]) : null; +} + +function extractAccuracyLevel(cell) { + const text = cell.textContent; + if (text.includes("high")) return "high"; + if (text.includes("medium")) return "medium"; + if (text.includes("low")) return "low"; + return "unknown"; +} + +function extractCoordinates(cell) { + // This would need to be enhanced based on actual data structure + // For now, return placeholder + return "Coordinates available"; +} + +function initializeDateRangeFilters() { + const dateFromInput = document.getElementById("date_from"); + const dateToInput = document.getElementById("date_to"); + + if (dateFromInput && dateToInput) { + // Set max date to today + const today = new Date().toISOString().split("T")[0]; + dateFromInput.max = today; + dateToInput.max = today; + + // Add validation to ensure 'from' date is not after 'to' date + dateFromInput.addEventListener("change", function () { + if (dateToInput.value && this.value > dateToInput.value) { + dateToInput.value = this.value; + } + }); + + dateToInput.addEventListener("change", function () { + if (dateFromInput.value && this.value < dateFromInput.value) { + dateFromInput.value = this.value; + } + }); + } } function setupEventListeners() { - // Entries per page change - const entriesSelect = document.getElementById('entriesPerPage'); - if (entriesSelect) { - entriesSelect.addEventListener('change', changeEntriesPerPage); - } - - // Filter form - const filtersForm = document.getElementById('filtersForm'); - if (filtersForm) { - filtersForm.addEventListener('submit', function(e) { - e.preventDefault(); - applyFilters(); - }); - } - - // Real-time employee filter - const employeeFilter = document.getElementById('employee'); - if (employeeFilter) { - employeeFilter.addEventListener('input', debounce(applyFilters, 300)); - } - - // Date and location filters - const dateFilter = document.getElementById('date'); - const locationFilter = document.getElementById('location'); - - if (dateFilter) { - dateFilter.addEventListener('change', applyFilters); - } - - if (locationFilter) { - locationFilter.addEventListener('change', applyFilters); - } -} + // Enhanced search and filter listeners + const searchInput = document.getElementById("searchInput"); + const locationFilter = document.getElementById("location"); + const employeeFilter = document.getElementById("employee"); -function changeEntriesPerPage() { - const select = document.getElementById('entriesPerPage'); - entriesPerPage = select.value === 'all' ? filteredData.length : parseInt(select.value); - currentPage = 1; - updateTable(); - updatePagination(); + if (searchInput) { + searchInput.addEventListener("input", debounce(applyFilters, 300)); + } + + if (locationFilter) { + locationFilter.addEventListener("change", applyFilters); + } + + if (employeeFilter) { + employeeFilter.addEventListener("input", debounce(applyFilters, 300)); + } + + // Entries per page listener + const entriesSelect = document.getElementById("entriesPerPage"); + if (entriesSelect) { + entriesSelect.addEventListener("change", changeEntriesPerPage); + } + + // Modal close listeners + window.addEventListener("click", function (event) { + const recordModal = document.getElementById("recordModal"); + const mapModal = document.getElementById("mapModal"); + + if (event.target === recordModal) { + closeModal(); + } + if (event.target === mapModal) { + closeMapModal(); + } + }); + + // Keyboard shortcuts + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + closeModal(); + closeMapModal(); + } + }); } function applyFilters() { - const dateFilter = document.getElementById('date')?.value || ''; - const locationFilter = document.getElementById('location')?.value || ''; - const employeeFilter = document.getElementById('employee')?.value.toLowerCase() || ''; - - filteredData = attendanceData.filter(record => { - const matchesDate = !dateFilter || record.date === dateFilter; - const matchesLocation = !locationFilter || record.location === locationFilter; - const matchesEmployee = !employeeFilter || - record.employeeId.toLowerCase().includes(employeeFilter); - - return matchesDate && matchesLocation && matchesEmployee; - }); - - currentPage = 1; - updateTable(); - updatePagination(); - updateFilterStats(); -} + const searchTerm = + document.getElementById("searchInput")?.value.toLowerCase() || ""; + const locationFilter = document.getElementById("location")?.value || ""; + const employeeFilter = + document.getElementById("employee")?.value.toLowerCase() || ""; -function clearFilters() { - // Clear form inputs - const form = document.getElementById('filtersForm'); - if (form) { - form.reset(); - } - - // Reset filtered data - filteredData = [...attendanceData]; - currentPage = 1; - - // Update display - updateTable(); - updatePagination(); - updateFilterStats(); - - // Update URL without filters - const url = new URL(window.location); - url.search = ''; - window.history.pushState({}, '', url); + filteredData = attendanceData.filter((record) => { + const matchesSearch = + !searchTerm || + record.employeeId.toLowerCase().includes(searchTerm) || + record.location.toLowerCase().includes(searchTerm) || + record.event.toLowerCase().includes(searchTerm); + + const matchesLocation = + !locationFilter || record.location === locationFilter; + const matchesEmployee = + !employeeFilter || + record.employeeId.toLowerCase().includes(employeeFilter); + + return matchesSearch && matchesLocation && matchesEmployee; + }); + + currentPage = 1; + updateTable(); + updatePagination(); + updateFilterStats(); } function sortTable(columnIndex) { - const headers = ['index', 'employeeId', 'location', 'event', 'date', 'time', 'device', 'status']; - const column = headers[columnIndex]; - - if (sortColumn === columnIndex) { - sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; - } else { - sortColumn = columnIndex; - sortDirection = 'asc'; + if (sortColumn === columnIndex) { + sortDirection = sortDirection === "asc" ? "desc" : "asc"; + } else { + sortColumn = columnIndex; + sortDirection = "asc"; + } + + const sortKey = getSortKey(columnIndex); + + filteredData.sort((a, b) => { + let aVal = a[sortKey]; + let bVal = b[sortKey]; + + // Handle numeric values for accuracy + if (columnIndex === 8 && aVal !== null && bVal !== null) { + aVal = parseFloat(aVal); + bVal = parseFloat(bVal); } - - filteredData.sort((a, b) => { - let aVal = a[column]; - let bVal = b[column]; - - // Handle different data types - if (column === 'date' || column === 'time') { - aVal = new Date(column === 'date' ? aVal : `2000-01-01 ${aVal}`); - bVal = new Date(column === 'date' ? bVal : `2000-01-01 ${bVal}`); - } else if (column === 'index') { - aVal = parseInt(aVal); - bVal = parseInt(bVal); - } else { - aVal = aVal.toString().toLowerCase(); - bVal = bVal.toString().toLowerCase(); - } - - if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; - if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; - return 0; - }); - - updateTable(); - updateSortIndicators(columnIndex); + + // Handle null values + if (aVal === null || aVal === undefined) aVal = ""; + if (bVal === null || bVal === undefined) bVal = ""; + + if (typeof aVal === "string") { + aVal = aVal.toLowerCase(); + bVal = bVal.toLowerCase(); + } + + let result; + if (aVal < bVal) result = -1; + else if (aVal > bVal) result = 1; + else result = 0; + + return sortDirection === "asc" ? result : -result; + }); + + updateTable(); + updateSortIndicators(columnIndex); +} + +function getSortKey(columnIndex) { + const sortKeys = [ + "index", + "employeeId", + "location", + "event", + "date", + "time", + "qr_address", + "checked_in_address", + "accuracy", + "device", + ]; + return sortKeys[columnIndex] || "index"; } function updateSortIndicators(activeColumn) { - const headers = document.querySelectorAll('th[onclick]'); - headers.forEach((header, index) => { - const icon = header.querySelector('i'); - if (icon) { - if (index === activeColumn) { - icon.className = `fas fa-sort-${sortDirection === 'asc' ? 'up' : 'down'}`; - } else { - icon.className = 'fas fa-sort'; - } - } - }); + // Update sort indicators in table headers + const headers = document.querySelectorAll(".attendance-table th"); + headers.forEach((header, index) => { + const icon = header.querySelector("i"); + if (icon) { + icon.className = "fas fa-sort"; + if (index === activeColumn) { + icon.className = + sortDirection === "asc" ? "fas fa-sort-up" : "fas fa-sort-down"; + } + } + }); } function updateTable() { - const tbody = document.querySelector('#attendanceTable tbody'); - if (!tbody) return; - - // Calculate pagination - const startIndex = (currentPage - 1) * entriesPerPage; - const endIndex = entriesPerPage === filteredData.length ? - filteredData.length : - Math.min(startIndex + entriesPerPage, filteredData.length); - - // Hide all rows first - attendanceData.forEach(record => { - if (record.element) { - record.element.style.display = 'none'; - } - }); - - // Show filtered and paginated rows - const visibleData = filteredData.slice(startIndex, endIndex); - visibleData.forEach((record, index) => { - if (record.element) { - record.element.style.display = ''; - // Update row number - const firstCell = record.element.querySelector('td:first-child'); - if (firstCell) { - firstCell.textContent = startIndex + index + 1; - } - } - }); - - // Show empty state if no data - showEmptyStateIfNeeded(); + const table = document.getElementById("attendanceTable"); + if (!table) return; + + const tbody = table.querySelector("tbody"); + const startIndex = (currentPage - 1) * entriesPerPage; + const endIndex = + entriesPerPage === "all" + ? filteredData.length + : startIndex + entriesPerPage; + const pageData = filteredData.slice(startIndex, endIndex); + + tbody.innerHTML = ""; + + pageData.forEach((record, index) => { + const row = createTableRow(record, startIndex + index + 1); + tbody.appendChild(row); + }); + + // Update any dynamic elements + updateFilterStats(); } -function showEmptyStateIfNeeded() { - const tbody = document.querySelector('#attendanceTable tbody'); - let emptyRow = tbody.querySelector('.empty-row'); - - if (filteredData.length === 0) { - if (!emptyRow) { - emptyRow = document.createElement('tr'); - emptyRow.className = 'empty-row'; - emptyRow.innerHTML = ` - -
- -

No Records Found

-

No attendance records match your current filters.

- -
- - `; - tbody.appendChild(emptyRow); - } - emptyRow.style.display = ''; - } else if (emptyRow) { - emptyRow.style.display = 'none'; - } +function createTableRow(record, displayIndex) { + const row = document.createElement("tr"); + row.dataset.recordId = record.id; + + // Create accuracy badge HTML + const accuracyBadge = + record.accuracy !== null + ? ` + + ${record.accuracy.toFixed(1)}m + (${record.accuracy_level}) + ` + : ` + + No GPS + `; + + row.innerHTML = ` + ${displayIndex} + +
+ ${record.employeeId} +
+ + +
+ + ${record.location} +
+ + +
+ ${record.event} +
+ + +
+ ${record.date} +
+ + +
+ ${record.time} +
+ + +
+ + + ${ + record.qr_address.length > 50 + ? record.qr_address.substring(0, 50) + "..." + : record.qr_address + } + +
+ + +
+ + + ${ + record.checked_in_address.length > 50 + ? record.checked_in_address.substring(0, 50) + "..." + : record.checked_in_address + } + +
+ + +
+ ${accuracyBadge} +
+ + +
+ + + ${ + record.device.length > 20 + ? record.device.substring(0, 20) + "..." + : record.device + } + +
+ + +
+ + + +
+ + `; + + return row; +} + +function changeEntriesPerPage() { + const select = document.getElementById("entriesPerPage"); + entriesPerPage = select.value === "all" ? "all" : parseInt(select.value); + currentPage = 1; + updateTable(); + updatePagination(); } function updatePagination() { - const container = document.getElementById('paginationContainer'); - if (!container) return; - - const totalPages = Math.ceil(filteredData.length / entriesPerPage); - - if (totalPages <= 1) { - container.innerHTML = ''; - return; - } - - let paginationHTML = '"; + + // Add pagination info + const startRecord = (currentPage - 1) * entriesPerPage + 1; + const endRecord = Math.min(currentPage * entriesPerPage, filteredData.length); + + paginationHTML += `
- Showing ${startRecord} to ${endRecord} of ${filteredData.length} entries - ${filteredData.length !== attendanceData.length ? - `(filtered from ${attendanceData.length} total entries)` : ''} + Showing ${startRecord} to ${endRecord} of ${ + filteredData.length + } entries + ${ + filteredData.length !== attendanceData.length + ? `(filtered from ${attendanceData.length} total entries)` + : "" + }
`; - - container.innerHTML = paginationHTML; + + container.innerHTML = paginationHTML; } function goToPage(page) { - const totalPages = Math.ceil(filteredData.length / entriesPerPage); - - if (page < 1 || page > totalPages) return; - - currentPage = page; - updateTable(); - updatePagination(); - - // Scroll to top of table - const table = document.getElementById('attendanceTable'); - if (table) { - table.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } + const totalPages = Math.ceil(filteredData.length / entriesPerPage); + + if (page < 1 || page > totalPages) return; + + currentPage = page; + updateTable(); + updatePagination(); + + // Scroll to top of table + const table = document.getElementById("attendanceTable"); + if (table) { + table.scrollIntoView({ behavior: "smooth", block: "start" }); + } } function updateFilterStats() { - // Update stats display if needed - const totalRecords = filteredData.length; - console.log(`Filtered records: ${totalRecords}`); -} - -// Record actions -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 = ` -
-
-
- Employee ID: - ${record.employeeId} -
-
- Location: - ${record.location} -
-
- Event: - ${record.event} -
-
- Date: - ${record.date} -
-
- Time: - ${record.time} -
-
- Device: - ${record.device} -
-
- Status: - - ${record.status} - -
-
-
- `; - - modal.style.display = 'flex'; - setTimeout(() => modal.classList.add('show'), 10); + // Update stats display if needed + const totalRecords = filteredData.length; + console.log(`Filtered records: ${totalRecords}`); } +// Enhanced record actions function editRecord(recordId) { - // Placeholder for edit functionality - alert(`Edit functionality for record ${recordId} would be implemented here.`); + console.log(`Edit record: ${recordId}`); + // Implement edit functionality + alert("Edit functionality to be implemented"); } -function deleteRecord(recordId) { - const record = attendanceData.find(r => r.id == recordId); - if (!record) return; - - const confirmed = confirm( - `Are you sure you want to delete the attendance record for ${record.employeeId}?\n\n` + - `Date: ${record.date}\n` + - `Time: ${record.time}\n` + - `Location: ${record.location}\n\n` + - 'This action cannot be undone.' - ); - - if (confirmed) { - // Here you would make an API call to delete the record - console.log(`Deleting record ${recordId}`); - - // For demo purposes, just remove from current data - const index = attendanceData.findIndex(r => r.id == recordId); - if (index > -1) { - // Remove from DOM - if (attendanceData[index].element) { - attendanceData[index].element.remove(); - } - - // Remove from data arrays - attendanceData.splice(index, 1); - const filteredIndex = filteredData.findIndex(r => r.id == recordId); - if (filteredIndex > -1) { - filteredData.splice(filteredIndex, 1); - } - - // Update display - updateTable(); - updatePagination(); - - showToast('Record deleted successfully', 'success'); - } - } +function closeModal() { + const modal = document.getElementById("recordModal"); + if (modal) { + modal.style.display = "none"; + } } -function closeRecordModal() { - const modal = document.getElementById('recordModal'); - if (modal) { - modal.classList.remove('show'); - setTimeout(() => { - modal.style.display = 'none'; - }, 200); - } -} - -// Export functionality -function exportAttendance() { - const exportData = filteredData.map(record => ({ - 'Employee ID': record.employeeId, - 'Location': record.location, - 'Event': record.event, - 'Date': record.date, - 'Time': record.time, - 'Device': record.device, - 'Status': record.status - })); - - const csv = convertToCSV(exportData); - downloadCSV(csv, `attendance_report_${new Date().toISOString().split('T')[0]}.csv`); - - showToast('Attendance data exported successfully', 'success'); -} - -function convertToCSV(data) { - if (!data.length) return ''; - - const headers = Object.keys(data[0]); - const csvContent = [ - headers.join(','), - ...data.map(row => - headers.map(header => { - const value = row[header]; - // Escape commas and quotes - return typeof value === 'string' && (value.includes(',') || value.includes('"')) - ? `"${value.replace(/"/g, '""')}"` - : value; - }).join(',') - ) - ].join('\n'); - - return csvContent; -} - -function downloadCSV(csv, filename) { - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - - if (link.download !== undefined) { - const url = URL.createObjectURL(blob); - link.setAttribute('href', url); - link.setAttribute('download', filename); - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } -} - -function refreshReport() { - showToast('Refreshing report...', 'info'); - setTimeout(() => { - window.location.reload(); - }, 500); -} - -// Charts initialization +// Chart initialization (placeholder) function initializeCharts() { - loadAttendanceStats(); + console.log("Initializing charts..."); + // Chart implementation would go here } -function loadAttendanceStats() { - fetch('/api/attendance/stats') - .then(response => response.json()) - .then(data => { - createDailyChart(data.daily_stats || []); - createLocationChart(data.location_stats || []); - }) - .catch(error => { - console.error('Error loading attendance stats:', error); - }); +function loadAttendanceData() { + console.log("Loading attendance data for charts..."); + // Additional data loading for charts would go here } -function createDailyChart(dailyStats) { - const ctx = document.getElementById('dailyChart'); - if (!ctx) return; - - if (dailyChart) { - dailyChart.destroy(); - } - - dailyChart = new Chart(ctx, { - type: 'line', - data: { - labels: dailyStats.map(stat => stat.date), - datasets: [{ - label: 'Check-ins', - data: dailyStats.map(stat => stat.checkins), - borderColor: '#2563eb', - backgroundColor: 'rgba(37, 99, 235, 0.1)', - tension: 0.4, - fill: true - }, { - label: 'Unique Employees', - data: dailyStats.map(stat => stat.employees), - borderColor: '#059669', - backgroundColor: 'rgba(5, 150, 105, 0.1)', - tension: 0.4, - fill: true - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: 'top' - } - }, - scales: { - y: { - beginAtZero: true, - ticks: { - stepSize: 1 - } - } - } - } - }); -} - -function createLocationChart(locationStats) { - const ctx = document.getElementById('locationChart'); - if (!ctx) return; - - if (locationChart) { - locationChart.destroy(); - } - - locationChart = new Chart(ctx, { - type: 'doughnut', - data: { - labels: locationStats.map(stat => stat.location), - datasets: [{ - data: locationStats.map(stat => stat.checkins), - backgroundColor: [ - '#2563eb', - '#059669', - '#d97706', - '#dc2626', - '#7c3aed', - '#0891b2', - '#65a30d', - '#c2410c' - ] - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: 'right' - } - } - } - }); -} - -// Utility functions +// Utility function function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; } - -function showToast(message, type = 'info') { - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.innerHTML = ` -
- - ${message} -
- `; - - toast.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - padding: 1rem; - z-index: 1000; - opacity: 0; - transform: translateX(100%); - transition: all 0.3s ease; - border-left: 4px solid ${getToastColor(type)}; - max-width: 400px; - `; - - document.body.appendChild(toast); - - setTimeout(() => { - toast.style.opacity = '1'; - toast.style.transform = 'translateX(0)'; - }, 100); - - setTimeout(() => { - toast.style.opacity = '0'; - toast.style.transform = 'translateX(100%)'; - setTimeout(() => { - if (document.body.contains(toast)) { - document.body.removeChild(toast); - } - }, 300); - }, 3000); -} - -function getToastIcon(type) { - const icons = { - success: 'fa-check-circle', - error: 'fa-exclamation-circle', - warning: 'fa-exclamation-triangle', - info: 'fa-info-circle' - }; - return icons[type] || icons.info; -} - -function getToastColor(type) { - const colors = { - success: '#059669', - error: '#dc2626', - warning: '#d97706', - info: '#0891b2' - }; - return colors[type] || colors.info; -} - -// Global function exports -window.sortTable = sortTable; -window.changeEntriesPerPage = changeEntriesPerPage; -window.clearFilters = clearFilters; -window.goToPage = goToPage; -window.viewRecordDetails = viewRecordDetails; -window.editRecord = editRecord; -window.deleteRecord = deleteRecord; -window.closeRecordModal = closeRecordModal; -window.exportAttendance = exportAttendance; -window.refreshReport = refreshReport; \ No newline at end of file diff --git a/templates/attendance_report.html b/templates/attendance_report.html index 1b31952..39693df 100644 --- a/templates/attendance_report.html +++ b/templates/attendance_report.html @@ -18,7 +18,7 @@ Attendance Report -

Monitor and analyze staff attendance across all locations

+

Monitor and analyze staff attendance with location accuracy tracking

@@ -33,7 +33,7 @@
- +
@@ -65,7 +65,7 @@

{{ stats.active_locations or 0 }}

Active Locations

- With check-ins + QR codes deployed
@@ -76,46 +76,74 @@

{{ stats.today_checkins or 0 }}

Today's Check-ins

- {{ current_date_formatted or 'Today' }} + {{ current_date_formatted }} +
+
+ + +
+
+ +
+
+

{{ stats.records_with_gps or 0 }}

+

GPS Records

+ Location captured +
+
+ +
+
+ +
+
+

{{ "%.1f"|format(stats.avg_accuracy or 0) }}m

+

Avg. Accuracy

+ Location precision
- +
-
-
-

- - Filter Records -

-
- -
+
+
+
-
+ +
+ +