Files

1511 lines
49 KiB
JavaScript

/**
* 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 attendanceData = [];
let filteredData = [];
// Charts
let dailyChart = null;
let locationChart = null;
// Initialize page when DOM is loaded
document.addEventListener("DOMContentLoaded", function () {
console.log("Enhanced Attendance Report page initialized");
initializeReport();
loadAttendanceData();
initializeCharts();
setupEventListeners();
initializeDateRangeFilters();
initializeLiveUpdates();
});
function initializeReport() {
// Load data from table
loadTableData();
// Initialize pagination
updatePagination();
// Apply initial filters if any
applyFilters();
}
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() {
// Enhanced search and filter listeners
const searchInput = document.getElementById("searchInput");
const locationFilter = document.getElementById("location");
const employeeFilter = document.getElementById("employee");
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();
}
});
}
// Work-type codes that may be attached to an employee ID:
// "1234SP", "1234 PW", "PT1234", "1234C". The codes below mirror
// WORK_TYPE_CODES in utils/helpers.py and parse_employee_id_for_work_type()
// in working_hours_calculator.py — keep the three in sync.
/**
* Split an employee ID into its numeric base ID and work-type code.
* "1234" -> {baseId: "1234", workType: "regular"}
* "1234 SP" / "1234SP" / "SP1234" -> {baseId: "1234", workType: "SP"}
*/
function parseEmployeeIdWorkType(rawId) {
const id = String(rawId || "").trim().toUpperCase();
if (!id) {
return { baseId: "", workType: "regular" };
}
// Any non-alphanumeric run may separate the two parts: "1234 SP", "1234.PW"
const suffixMatch = id.match(/^([0-9]+)[^0-9A-Z]*(SP|PW|PT|C)$/);
if (suffixMatch) {
return { baseId: stripLeadingZeros(suffixMatch[1]), workType: suffixMatch[2] };
}
const prefixMatch = id.match(/^(SP|PW|PT|C)[^0-9A-Z]*([0-9]+)$/);
if (prefixMatch) {
return { baseId: stripLeadingZeros(prefixMatch[2]), workType: prefixMatch[1] };
}
return { baseId: stripLeadingZeros(id), workType: "regular" };
}
function stripLeadingZeros(value) {
// "01234" and "1234" are the same employee — the server tolerates padding too.
return value.replace(/^0+(?=[0-9])/, "");
}
function applyFilters() {
filteredData = filterRecords();
currentPage = 1;
updateTable();
updatePagination();
updateFilterStats();
}
/**
* Records matching the on-page search / location / employee filters.
* Shared by applyFilters() and refreshTableKeepingView() (live updates), which
* must keep the user's current page and sort instead of jumping to page 1.
*/
function filterRecords() {
const searchTerm =
document.getElementById("searchInput")?.value.toLowerCase() || "";
const locationFilter = document.getElementById("location")?.value || "";
// Support comma-separated multi-employee filter
const employeeFilterRaw =
document.getElementById("employee")?.value || "";
// Parse each selected ID into base + work type so this client-side pass
// keeps the rows the server already matched. A plain ID ("1234") matches
// the regular record AND every work-type variant (1234SP / 1234PW / 1234PT /
// 1234C); an ID that already carries a code matches only that code.
// This mirrors _work_type_codes_for() in utils/helpers.py — without it the
// extra-work rows returned by the query were filtered back out here.
const employeeFilterIds = employeeFilterRaw
? employeeFilterRaw
.split(",")
.map(function (s) { return s.trim(); })
.filter(Boolean)
.map(parseEmployeeIdWorkType)
: [];
return 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 && record.location.trim() === locationFilter.trim());
const recordEmployee = parseEmployeeIdWorkType(record.employeeId);
const matchesEmployee =
employeeFilterIds.length === 0 ||
employeeFilterIds.some(function (selected) {
if (selected.baseId !== recordEmployee.baseId) {
return false;
}
// Regular selection = all work types; explicit code = that code only
return (
selected.workType === "regular" ||
selected.workType === recordEmployee.workType
);
});
return matchesSearch && matchesLocation && matchesEmployee;
});
}
function sortTable(columnIndex) {
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);
}
// 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) {
// 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 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 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 || entriesPerPage === "all") {
if (container) container.innerHTML = "";
return;
}
const totalPages = Math.ceil(filteredData.length / entriesPerPage);
if (totalPages <= 1) {
container.innerHTML = "";
return;
}
let paginationHTML = '<div class="pagination">';
// Previous button
paginationHTML += `
<button onclick="goToPage(${currentPage - 1})"
class="pagination-btn"
${currentPage === 1 ? "disabled" : ""}>
<i class="fas fa-chevron-left"></i>
</button>
`;
// Page numbers
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
if (startPage > 1) {
paginationHTML += `<button onclick="goToPage(1)" class="pagination-btn">1</button>`;
if (startPage > 2) {
paginationHTML += '<span class="pagination-ellipsis">...</span>';
}
}
for (let i = startPage; i <= endPage; i++) {
paginationHTML += `
<button onclick="goToPage(${i})"
class="pagination-btn ${i === currentPage ? "active" : ""}">
${i}
</button>
`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
paginationHTML += '<span class="pagination-ellipsis">...</span>';
}
paginationHTML += `<button onclick="goToPage(${totalPages})" class="pagination-btn">${totalPages}</button>`;
}
// Next button
paginationHTML += `
<button onclick="goToPage(${currentPage + 1})"
class="pagination-btn"
${currentPage === totalPages ? "disabled" : ""}>
<i class="fas fa-chevron-right"></i>
</button>
`;
paginationHTML += "</div>";
// Add pagination info
const startRecord = (currentPage - 1) * entriesPerPage + 1;
const endRecord = Math.min(currentPage * entriesPerPage, filteredData.length);
paginationHTML += `
<div class="pagination-info">
Showing ${startRecord} to ${endRecord} of ${
filteredData.length
} entries
${
filteredData.length !== attendanceData.length
? `(filtered from ${attendanceData.length} total entries)`
: ""
}
</div>
`;
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" });
}
}
function updateFilterStats() {
// Update stats display if needed
const totalRecords = filteredData.length;
console.log(`Filtered records: ${totalRecords}`);
}
// Enhanced record actions
function editRecord(recordId) {
// Check permissions before allowing edit
if (!hasEditPermission) {
alert(
"Access denied. Only administrators can edit attendance records."
);
return;
}
console.log(`Edit record: ${recordId}`);
// Log the action
console.log(`[LOG] User attempting to edit attendance record: ${recordId}`);
// Redirect to edit page
window.location.href = `/attendance/${recordId}/edit`;
}
function deleteRecord(recordId, employeeId) {
// Check permissions before allowing delete
if (!hasEditPermission) {
alert(
"Access denied. Only administrators can delete attendance records."
);
return;
}
console.log(`Delete record: ${recordId}`);
// Confirmation dialog
const confirmMessage = `Are you sure you want to delete the attendance record for employee "${employeeId}"?\n\nThis action cannot be undone.`;
if (confirm(confirmMessage)) {
console.log(
`[LOG] User confirmed deletion of attendance record: ${recordId}`
);
// Send delete request
fetch(`/attendance/${recordId}/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '',
},
})
.then((response) => response.json())
.then((data) => {
if (data.success) {
console.log(
`[LOG] Successfully deleted attendance record: ${recordId}`
);
alert("Attendance record deleted successfully!");
window.location.reload();
} else {
console.error(
`[LOG] Failed to delete attendance record: ${recordId} - ${data.message}`
);
alert(data.message || "Error deleting record. Please try again.");
}
})
.catch((error) => {
console.error(
`[LOG] Error during attendance record deletion: ${recordId}`,
error
);
alert("Error deleting record. Please try again.");
});
}
}
function closeModal() {
const modal = document.getElementById("recordModal");
if (modal) {
modal.style.display = "none";
}
}
// Chart initialization (placeholder)
function initializeCharts() {
console.log("Initializing charts...");
// Chart implementation would go here
}
function loadAttendanceData() {
console.log("Loading attendance data for charts...");
// Additional data loading for charts would go here
}
// Utility function
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Enhanced JavaScript functions for location accuracy features
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");
// Extract verification data from the accuracy badge
const verificationData = extractVerificationData(cells[9]);
return {
id: row.dataset.recordId,
index: index + 1,
employeeId: cells[1] ? cells[1].textContent.trim() : "",
employeeName: cells[2] ? cells[2].textContent.trim() : "", // NEW: Employee Name column
location: cells[3] ? cells[3].textContent.trim() : "", // Updated from cells[2]
event: cells[4] ? cells[4].textContent.trim() : "", // Updated from cells[3]
date: cells[5] ? cells[5].textContent.trim() : "", // Updated from cells[4]
time: cells[6] ? cells[6].textContent.trim() : "", // Updated from cells[5]
qr_address: cells[7] // Updated from cells[6]
? cells[7].getAttribute("title") || cells[7].textContent.trim()
: "",
checked_in_address: cells[8] // Updated from cells[7]
? cells[8].getAttribute("title") || cells[8].textContent.trim()
: "",
// FIXED: Extract location accuracy for address display logic
location_accuracy: cells[9] ? extractLocationAccuracy(cells[9]) : null, // Updated from cells[8]
accuracy_level: cells[9] // Updated from cells[8]
? extractLocationAccuracyLevel(cells[9])
: "unknown",
device: cells[10] // Updated from cells[9]
? cells[10].textContent.trim()
: "",
isModified: row.classList.contains('modified-record'),
isDynamic: row.dataset.isDynamic === '1',
verification_required: verificationData.required,
verification_status: verificationData.status
};
});
filteredData = [...attendanceData];
console.log(`Loaded ${attendanceData.length} attendance records`);
// Debug log for location accuracy data
const recordsWithAccuracy = attendanceData.filter(
(r) => r.location_accuracy !== null
);
console.log(
`Records with location accuracy: ${recordsWithAccuracy.length}`
);
if (recordsWithAccuracy.length > 0) {
console.log(
`Sample location accuracy values:`,
recordsWithAccuracy.slice(0, 3).map((r) => r.location_accuracy)
);
}
}
}
function extractLocationAccuracy(cell) {
const text = cell.textContent;
console.log(`Extracting accuracy from: "${text}"`);
// Look for miles pattern (e.g., "0.003 mi", "1.234 mi")
const milesMatch = text.match(/(\d+\.?\d*)\s*mi/);
if (milesMatch) {
const value = parseFloat(milesMatch[1]);
console.log(`Found miles: ${value}`);
return value;
}
// Look for specific accuracy patterns in the HTML
const accuracyMatch = text.match(/accuracy[:\s]*(\d+\.?\d*)/i);
if (accuracyMatch) {
const value = parseFloat(accuracyMatch[1]);
console.log(`Found accuracy: ${value}`);
return value;
}
// Check for data attributes
const dataAccuracy = cell.getAttribute("data-accuracy");
if (dataAccuracy) {
const value = parseFloat(dataAccuracy);
console.log(`Found data-accuracy: ${value}`);
return value;
}
// Fallback: look for GPS accuracy in meters and convert to miles (approximate)
const metersMatch = text.match(/(\d+\.?\d*)\s*m/);
if (metersMatch) {
const meters = parseFloat(metersMatch[1]);
const miles = meters * 0.000621371; // Convert meters to miles (approximate)
console.log(`Found meters: ${meters}, converted to miles: ${miles}`);
return miles;
}
console.log(`No accuracy found in: "${text}"`);
return null;
}
function extractLocationAccuracyLevel(cell) {
// Get the numerical accuracy value from the cell
const accuracy = extractLocationAccuracy(cell);
// Return 2-level accuracy based on 0.5-mile threshold
if (accuracy !== null && accuracy !== undefined) {
return accuracy < 0.3 ? "accurate" : "inaccurate";
}
return "unknown";
}
function extractVerificationData(cell) {
// Extract verification status from badge classes in the HTML
if (!cell) {
console.log('extractVerificationData: No cell provided');
return { required: false, status: null };
}
const badge = cell.querySelector('.location-accuracy-badge');
if (!badge) {
console.log('extractVerificationData: No badge found in cell');
return { required: false, status: null };
}
console.log('extractVerificationData: Badge classes:', badge.className);
// Check badge classes for verification status
if (badge.classList.contains('badge-review-needed')) {
console.log('extractVerificationData: Found pending verification');
return { required: true, status: 'pending' };
} else if (badge.classList.contains('badge-verified')) {
console.log('extractVerificationData: Found approved verification');
return { required: true, status: 'approved' };
} else if (badge.classList.contains('badge-rejected')) {
console.log('extractVerificationData: Found rejected verification');
return { required: true, status: 'rejected' };
}
console.log('extractVerificationData: No verification status found, standard badge');
return { required: false, status: null };
}
function createTableRow(record, displayIndex) {
const row = document.createElement("tr");
row.dataset.recordId = record.id;
// Apply highlighting if record was modified
if (record.isModified) {
row.classList.add('modified-record');
}
// Apply blue-border highlight for Dynamic QR records
if (record.isDynamic) {
row.classList.add('dynamic-qr-record');
}
// Brief highlight for rows delivered by live updates
if (record.isLiveNew) {
row.classList.add('live-new-record');
}
// Debug logging for first few records
if (displayIndex <= 3) {
console.log(`=== CREATING ROW ${displayIndex} ===`);
console.log(`Employee: ${record.employeeId}`);
console.log(`Location accuracy: ${record.location_accuracy}`);
console.log(`Verification required: ${record.verification_required}`);
console.log(`Verification status: ${record.verification_status}`);
console.log(`QR address: ${record.qr_address}`);
console.log(`Check-in address: ${record.checked_in_address}`);
}
// Create location accuracy badge HTML - check verification status first
let locationAccuracyBadge;
if (record.verification_required && record.verification_status === 'pending') {
// Show Review Needed badge for pending verification - LINK to review page
locationAccuracyBadge = `<a href="/verification-review/${encodeURIComponent(record.id)}"
class="location-accuracy-badge badge-review-needed"
style="cursor: pointer; text-decoration: none;"
title="Click to review verification photo - Distance: ${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} miles">
<i class="fas fa-exclamation-triangle"></i>
Review Needed
<small>(${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} mi)</small>
</a>`;
} else if (record.verification_status === 'approved') {
// Show Verified badge for approved verification
locationAccuracyBadge = `<span class="location-accuracy-badge badge-verified"
title="Verification approved - Distance: ${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} miles">
<i class="fas fa-check-circle"></i>
Verified
<small>(${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} mi)</small>
</span>`;
} else if (record.verification_status === 'rejected') {
// Show Rejected badge for rejected verification
locationAccuracyBadge = `<span class="location-accuracy-badge badge-rejected"
title="Verification rejected - Distance: ${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} miles">
<i class="fas fa-times-circle"></i>
Rejected
<small>(${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} mi)</small>
</span>`;
} else if (record.location_accuracy !== null) {
// Show standard location accuracy badge
locationAccuracyBadge = `<span class="location-accuracy-badge accuracy-${
record.accuracy_level
}"
title="Distance between QR location and check-in location: ${
record.location_accuracy
} miles - ${record.accuracy_level}">
<i class="fas fa-ruler"></i>
${record.location_accuracy.toFixed(3)} mi
<small>(${record.accuracy_level})</small>
</span>`;
} else {
// No accuracy data
locationAccuracyBadge = `<span class="location-accuracy-badge accuracy-unknown" title="Location accuracy could not be calculated">
<i class="fas fa-question-circle"></i>
Unknown
</span>`;
}
// Address display logic based on location accuracy
let addressDisplayHTML = "";
let addressToShow = record.checked_in_address;
let addressIcon = "fas fa-location-arrow";
let addressClass = "address-normal-accuracy";
let addressTitle = `Check-in Address: ${record.checked_in_address}`;
// Apply 0.5-mile threshold logic
if (
record.location_accuracy !== null &&
record.location_accuracy !== undefined
) {
const accuracy = parseFloat(record.location_accuracy);
if (displayIndex <= 3) {
console.log(`Applying address logic for ${record.employeeId}:`);
console.log(` Accuracy value: ${accuracy}`);
console.log(` Is <= 0.3? ${accuracy <= 0.3}`);
}
if (!isNaN(accuracy) && accuracy <= 0.3) {
// High accuracy - use QR address
addressToShow = record.qr_address;
addressIcon = "fas fa-check-circle";
addressClass = "address-high-accuracy";
addressTitle = `QR Address (High Accuracy ≤ 0.5 mi): ${record.qr_address}`;
if (displayIndex <= 3) {
console.log(` → Using QR address: ${addressToShow}`);
}
addressDisplayHTML = `
<i class="${addressIcon}" style="color: #059669; margin-right: 4px;"
title="High accuracy - showing QR location"></i>
<span title="${escapeHtml(addressTitle)}" class="${addressClass}">
${escapeHtml(truncateText(addressToShow, 45))}
</span>
`;
} else {
// Lower accuracy - use check-in address
if (displayIndex <= 3) {
console.log(` → Using check-in address: ${addressToShow}`);
}
addressDisplayHTML = `
<i class="fas fa-exclamation-triangle" style="color: #f59e0b; margin-right: 4px;"
title="Lower accuracy - showing actual check-in location"></i>
<span title="${escapeHtml(addressTitle)} (Accuracy: ${accuracy.toFixed(
3
)} mi)" class="${addressClass}">
${escapeHtml(truncateText(addressToShow, 45))}
</span>
`;
}
} else {
// No accuracy data - use check-in address
if (displayIndex <= 3) {
console.log(
` → No accuracy data, using check-in address: ${addressToShow}`
);
}
addressDisplayHTML = `
<i class="${addressIcon}"></i>
<span title="${escapeHtml(addressTitle)}" class="${addressClass}">
${escapeHtml(truncateText(addressToShow, 45))}
</span>
`;
}
row.innerHTML = `
<td>${displayIndex}</td>
<td>
<div class="employee-info">
<span class="employee-id">${escapeHtml(record.employeeId)}</span>
</div>
</td>
<td>
<div class="employee-name">
<i class="fas fa-user"></i>
<span>${escapeHtml(record.employeeName || 'Unknown')}</span>
</div>
</td>
<td>
<div class="location-info">
<i class="fas fa-map-marker-alt"></i>
${escapeHtml(record.location)}
</div>
</td>
<td>
<div class="event-info">
${escapeHtml(record.event)}
</div>
</td>
<td>
<div class="date-info">
${escapeHtml(record.date)}
</div>
</td>
<td>
<div class="time-info">
${escapeHtml(record.time)}
</div>
</td>
<td>
<div class="address-info qr-address">
<i class="fas fa-qrcode" style="color: #6366f1; margin-right: 4px;" title="QR Code Address (Fixed)"></i>
<span title="QR Address: ${escapeHtml(record.qr_address)}">
${escapeHtml(truncateText(record.qr_address, 50))}
</span>
</div>
</td>
<td>
<div class="address-info checkin-address">
${addressDisplayHTML}
</div>
</td>
<td>
<div class="location-accuracy-info">
${locationAccuracyBadge}
</div>
</td>
<td>
<div class="device-info">
<i class="fas fa-mobile-alt"></i>
<span title="${escapeHtml(record.device)}">
${escapeHtml(truncateText(record.device, 20))}
</span>
</div>
</td>
<td>
<div class="record-actions">
${
record.verification_required && record.verification_status === 'pending'
? `<a href="/verification-review/${encodeURIComponent(record.id)}"
class="action-btn btn-review"
title="Review Verification Photo">
<i class="fas fa-camera"></i>
</a>`
: ''
}
${
hasEditPermission
? `
<button onclick="editRecord('${escapeHtml(escapeJsString(record.id))}')"
class="action-btn btn-edit"
title="Edit Record">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteRecord('${escapeHtml(escapeJsString(record.id))}', '${escapeHtml(escapeJsString(record.employeeId))}')"
class="action-btn btn-delete"
title="Delete Record">
<i class="fas fa-trash"></i>
</button>
`
: `
<span class="text-muted" title="Admin access required">
<i class="fas fa-lock"></i>
</span>
`
}
</div>
</td>
`;
return row;
}
function getSortKey(columnIndex) {
const sortKeys = [
"index",
"employeeId",
"location",
"event",
"date",
"time",
"qr_address",
"checked_in_address",
"location_accuracy",
"device",
];
return sortKeys[columnIndex] || "index";
}
// Enhanced sorting for location accuracy (numeric sorting)
function sortTable(columnIndex) {
if (sortColumn === columnIndex) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortColumn = columnIndex;
sortDirection = "asc";
}
sortFilteredData();
updateTable();
updateSortIndicators(columnIndex);
}
/**
* Sort filteredData by the current sortColumn / sortDirection without toggling
* the direction — reused when live updates re-apply the user's chosen sort.
*/
function sortFilteredData() {
const columnIndex = sortColumn;
const sortKey = getSortKey(columnIndex);
filteredData.sort((a, b) => {
let aVal = a[sortKey];
let bVal = b[sortKey];
// Handle numeric values for location accuracy
if (columnIndex === 8 && aVal !== null && bVal !== null) {
aVal = parseFloat(aVal);
bVal = parseFloat(bVal);
}
// Handle null values - put them at the end
if (aVal === null || aVal === undefined) {
return sortDirection === "asc" ? 1 : -1;
}
if (bVal === null || bVal === undefined) {
return sortDirection === "asc" ? -1 : 1;
}
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;
});
}
// Enhanced statistics display for location accuracy
function updateFilterStats() {
const totalRecords = filteredData.length;
const recordsWithAccuracy = filteredData.filter(
(r) => r.location_accuracy !== null
).length;
const avgAccuracy =
recordsWithAccuracy > 0
? filteredData
.filter((r) => r.location_accuracy !== null)
.reduce((sum, r) => sum + r.location_accuracy, 0) /
recordsWithAccuracy
: 0;
console.log(`Filtered records: ${totalRecords}`);
console.log(`Records with location accuracy: ${recordsWithAccuracy}`);
console.log(`Average location accuracy: ${avgAccuracy.toFixed(3)} miles`);
}
// Function to get accuracy level color for charts or displays
function getAccuracyLevelColor(level) {
const colors = {
accurate: "#059669", // green
inaccurate: "#dc2626", // red
unknown: "#6b7280", // gray
};
return colors[level] || colors["unknown"];
}
// Enhanced export function to include location accuracy
function exportAttendanceWithAccuracy() {
// Build CSV header with location accuracy
const headers = [
"#",
"Employee ID",
"Employee Name",
"Location",
"Event",
"Date",
"Time",
"QR Address",
"Check-in Address",
"Location Accuracy (miles)",
"Accuracy Level",
"Device",
];
// Build CSV rows
const rows = filteredData.map((record, index) => [
index + 1,
record.employeeId,
record.employeeName || "Unknown",
record.location,
record.event,
record.date,
record.time,
record.qr_address,
record.checked_in_address,
record.location_accuracy ? record.location_accuracy.toFixed(3) : "Unknown",
record.accuracy_level,
record.device,
]);
// Create CSV content
const csvContent = [headers, ...rows]
.map((row) => row.map((field) => `"${field}"`).join(","))
.join("\n");
// Download CSV
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute(
"download",
`attendance_report_with_accuracy_${
new Date().toISOString().split("T")[0]
}.csv`
);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportAttendance() {
// Check user role before proceeding
const userRole = window.userRole; // Read from global variable set in template
console.log("Template - session.role:", '{{ session.role }}');
console.log("Template - window.userRole set to:", window.userRole);
if (!['admin', 'payroll', 'accounting'].includes(userRole)) {
console.log("Export access denied - insufficient privileges");
alert("Access denied. Only administrators and payroll staff can export data.");
return;
}
// Log export action
console.log(`Export button clicked by ${userRole} - redirecting to configuration page`);
// Get current filters
const currentFilters = getCurrentFilters();
// Build URL with current filters
const params = new URLSearchParams();
if (currentFilters.date_from)
params.append("date_from", currentFilters.date_from);
if (currentFilters.date_to) params.append("date_to", currentFilters.date_to);
if (currentFilters.location)
params.append("location", currentFilters.location);
if (currentFilters.employee)
params.append("employee", currentFilters.employee);
if (currentFilters.project)
params.append("project", currentFilters.project);
// Navigate to export configuration page
const configUrl =
"/export-configuration" +
(params.toString() ? "?" + params.toString() : "");
window.location.href = configUrl;
}
function getCurrentFilters() {
// Extract current filter values from the page
return {
date_from: document.getElementById("date_from")?.value || "",
date_to: document.getElementById("date_to")?.value || "",
location: document.getElementById("location")?.value || "",
employee: document.getElementById("employee")?.value || "",
project: document.getElementById("project")?.value || "",
};
}
// Add a quick CSV export function as backup (keep existing functionality)
function exportAttendanceCSV() {
// Check user role before proceeding
const userRole = window.userRole;
if (!['admin', 'payroll', 'accounting'].includes(userRole)) {
console.log("CSV export access denied - insufficient privileges");
alert("Access denied. Only administrators and payroll staff can export data.");
return;
}
// Build export URL with current filters for CSV
const params = new URLSearchParams();
const filters = getCurrentFilters();
if (filters.date_from) params.append("date_from", filters.date_from);
if (filters.date_to) params.append("date_to", filters.date_to);
if (filters.location) params.append("location", filters.location);
if (filters.employee) params.append("employee", filters.employee);
if (filters.project) params.append("project", filters.project);
params.append("export", "csv");
// Create a temporary link and click it to download
const downloadUrl = window.location.pathname + "?" + params.toString();
window.open(downloadUrl, "_blank");
}
// Enhanced export menu (if you want to add dropdown with multiple export options)
function showExportMenu() {
// Create export options menu
const existingMenu = document.getElementById("exportMenu");
if (existingMenu) {
existingMenu.remove();
return;
}
const exportBtn = document.querySelector(
'button[onclick="exportAttendance()"]'
);
if (!exportBtn) return;
const menu = document.createElement("div");
menu.id = "exportMenu";
menu.style.cssText = `
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
z-index: 1000;
min-width: 200px;
margin-top: 5px;
`;
menu.innerHTML = `
<div style="padding: 0.5rem;">
<button onclick="exportAttendance(); closeExportMenu();"
style="width: 100%; padding: 0.75rem; border: none; background: none; text-align: left; cursor: pointer; border-radius: 4px;"
onmouseover="this.style.background='#f7fafc'"
onmouseout="this.style.background='none'">
<i class="fas fa-file-excel" style="color: #48bb78; margin-right: 0.5rem;"></i>
Excel Export (Customizable)
</button>
<button onclick="exportAttendanceCSV(); closeExportMenu();"
style="width: 100%; padding: 0.75rem; border: none; background: none; text-align: left; cursor: pointer; border-radius: 4px;"
onmouseover="this.style.background='#f7fafc'"
onmouseout="this.style.background='none'">
<i class="fas fa-file-csv" style="color: #4299e1; margin-right: 0.5rem;"></i>
Quick CSV Export
</button>
</div>
`;
exportBtn.parentElement.style.position = "relative";
exportBtn.parentElement.appendChild(menu);
// Close menu when clicking outside
setTimeout(() => {
document.addEventListener("click", function closeOnClickOutside(e) {
if (!menu.contains(e.target) && e.target !== exportBtn) {
closeExportMenu();
document.removeEventListener("click", closeOnClickOutside);
}
});
}, 100);
}
function closeExportMenu() {
const menu = document.getElementById("exportMenu");
if (menu) {
menu.remove();
}
}
// Initialize export functionality when page loads
document.addEventListener("DOMContentLoaded", function () {
// Update export button to use enhanced functionality
const exportBtn = document.querySelector(
'button[onclick="exportAttendance()"]'
);
if (exportBtn) {
// You can modify the button to show a dropdown instead
// exportBtn.onclick = showExportMenu;
// exportBtn.innerHTML = '<i class="fas fa-download"></i> Export Data <i class="fas fa-chevron-down" style="margin-left: 0.5rem;"></i>';
}
console.log("Enhanced export functionality initialized");
});
// ============================================================
// HTML SAFETY HELPERS
// ============================================================
// Row values are plain text (read with textContent, or JSON from the server)
// but createTableRow() builds markup with innerHTML. Device and address text
// come from the public check-in page, so every value is escaped first.
function escapeHtml(value) {
return String(value === null || value === undefined ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// For a value placed inside a single-quoted JS string in an onclick attribute:
// escape for JS here, then escapeHtml() for the attribute.
function escapeJsString(value) {
return String(value === null || value === undefined ? "" : value)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/[\r\n]+/g, " ");
}
// Truncate BEFORE escaping, so an entity like &amp; is never cut in half
function truncateText(value, maxLength) {
const text = String(value === null || value === undefined ? "" : value);
return text.length > maxLength ? text.substring(0, maxLength) + "..." : text;
}
// ============================================================
// LIVE UPDATES — new check-ins appear without reloading the page
// ============================================================
// Polls GET /api/attendance/live-updates (routes/attendance.py), which answers
// from a primary-key MAX(id) lookup when nothing is new. To keep the load
// negligible the poll:
// - runs every LIVE_POLL_INTERVAL_MS and never overlaps itself
// - pauses while the tab is hidden, and checks once as soon as it is shown
// - backs off exponentially after errors, up to LIVE_MAX_BACKOFF_MS
// - stops for good when the session has ended (redirect / 401 / 403)
// New rows keep the user's current page, sort and search.
const LIVE_POLL_INTERVAL_MS = 20000;
const LIVE_MAX_BACKOFF_MS = 5 * 60 * 1000;
const LIVE_HIGHLIGHT_MS = 8000;
const LIVE_MAX_RECORDS = 5000; // rows kept in memory on a page left open for days
const LIVE_FILTER_PARAMS = ["date_from", "date_to", "location", "employee", "project"];
const liveUpdates = {
enabled: false,
sinceId: 0,
timer: null,
inFlight: false,
failures: 0,
stopped: false,
newSinceLoad: 0,
statusEl: null,
};
function initializeLiveUpdates() {
const container = document.getElementById("attendanceReportContainer");
const sinceId = container ? parseInt(container.dataset.liveSinceId, 10) : NaN;
if (isNaN(sinceId)) {
return; // rendered without a cursor (no access / id lookup failed): stay static
}
liveUpdates.enabled = true;
liveUpdates.sinceId = sinceId;
createLiveStatusIndicator();
setLiveStatus("live");
document.addEventListener("visibilitychange", function () {
if (!liveUpdates.enabled || liveUpdates.stopped) return;
if (document.hidden) {
clearTimeout(liveUpdates.timer); // no requests from background tabs
} else {
pollLiveUpdates(); // catch up straight away when the tab is shown again
}
});
scheduleLivePoll(LIVE_POLL_INTERVAL_MS);
}
function scheduleLivePoll(delayMs) {
clearTimeout(liveUpdates.timer);
if (liveUpdates.stopped || document.hidden) return;
liveUpdates.timer = setTimeout(pollLiveUpdates, delayMs);
}
function pollLiveUpdates() {
if (!liveUpdates.enabled || liveUpdates.stopped || liveUpdates.inFlight || document.hidden) {
return;
}
clearTimeout(liveUpdates.timer);
liveUpdates.inFlight = true;
// The server applies the same filters the page was loaded with
const pageParams = new URLSearchParams(window.location.search);
const params = new URLSearchParams();
LIVE_FILTER_PARAMS.forEach(function (name) {
const value = pageParams.get(name);
if (value) params.set(name, value);
});
params.set("since_id", String(liveUpdates.sinceId));
let nextDelay = LIVE_POLL_INTERVAL_MS;
fetch("/api/attendance/live-updates?" + params.toString(), {
cache: "no-store",
credentials: "same-origin",
headers: { "X-Requested-With": "XMLHttpRequest", Accept: "application/json" },
})
.then(function (response) {
const contentType = response.headers.get("Content-Type") || "";
if (
response.redirected ||
response.status === 401 ||
response.status === 403 ||
(response.ok && contentType.indexOf("application/json") === -1)
) {
// login_required redirected to the login page: the session is over
stopLiveUpdates();
return null;
}
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
return response.json();
})
.then(function (data) {
if (!data) return;
if (!data.success) {
throw new Error(data.message || "live update failed");
}
liveUpdates.failures = 0;
if (Array.isArray(data.records) && data.records.length > 0) {
insertLiveRecords(data.records);
}
const latestId = parseInt(data.latest_id, 10);
if (!isNaN(latestId) && latestId > liveUpdates.sinceId) {
liveUpdates.sinceId = latestId;
}
if (data.has_more) {
nextDelay = 1000; // a large burst arrived: fetch the next batch promptly
}
if (!liveUpdates.stopped) setLiveStatus("live");
})
.catch(function (error) {
liveUpdates.failures += 1;
nextDelay = Math.min(
LIVE_POLL_INTERVAL_MS * Math.pow(2, liveUpdates.failures),
LIVE_MAX_BACKOFF_MS
);
console.log(
"Live updates: check failed (" + error.message + "), retrying in " +
Math.round(nextDelay / 1000) + "s"
);
setLiveStatus("retry");
})
.then(function () {
liveUpdates.inFlight = false;
if (!liveUpdates.stopped) scheduleLivePoll(nextDelay);
});
}
function stopLiveUpdates() {
liveUpdates.stopped = true;
clearTimeout(liveUpdates.timer);
setLiveStatus("stopped");
}
function insertLiveRecords(records) {
// The page showed "No Attendance Records Found" (no table to add to):
// reload once, keeping the filters, so the table renders normally.
if (!document.getElementById("attendanceTable")) {
stopLiveUpdates();
if (typeof refreshReport === "function") {
refreshReport();
} else {
window.location.reload();
}
return;
}
const knownIds = new Set(attendanceData.map(function (r) { return String(r.id); }));
const fresh = records
.filter(function (r) { return !knownIds.has(String(r.id)); })
.sort(function (a, b) { return Number(b.id) - Number(a.id); }); // newest first
if (fresh.length === 0) return;
fresh.forEach(function (r) { r.isLiveNew = true; });
attendanceData = fresh.concat(attendanceData);
if (attendanceData.length > LIVE_MAX_RECORDS) {
attendanceData.length = LIVE_MAX_RECORDS;
}
attendanceData.forEach(function (r, i) { r.index = i + 1; });
liveUpdates.newSinceLoad += fresh.length;
refreshTableKeepingView();
// Rows re-rendered after this (page change, sort) no longer highlight
setTimeout(function () {
fresh.forEach(function (r) { r.isLiveNew = false; });
}, LIVE_HIGHLIGHT_MS);
}
// Re-render after new rows arrive WITHOUT resetting the user's view:
// same search/filters, same sort, same page (clamped if it no longer exists).
function refreshTableKeepingView() {
filteredData = filterRecords();
if (sortColumn !== -1) {
sortFilteredData();
}
if (entriesPerPage !== "all") {
const totalPages = Math.max(1, Math.ceil(filteredData.length / entriesPerPage));
if (currentPage > totalPages) currentPage = totalPages;
}
updateTable();
updatePagination();
}
function createLiveStatusIndicator() {
const heading = document.querySelector(".attendance-table-section .table-header h3");
if (!heading) return;
const status = document.createElement("span");
status.id = "liveUpdateStatus";
status.className = "live-status";
const dot = document.createElement("span");
dot.className = "live-dot";
const text = document.createElement("span");
text.className = "live-text";
status.appendChild(dot);
status.appendChild(text);
heading.appendChild(status);
liveUpdates.statusEl = status;
}
function setLiveStatus(state) {
const status = liveUpdates.statusEl;
if (!status) return;
const text = status.querySelector(".live-text");
status.classList.remove("live-status--retry", "live-status--stopped");
if (state === "retry") {
status.classList.add("live-status--retry");
text.textContent = "Reconnecting…";
status.title = "Could not check for new records. Retrying automatically.";
} else if (state === "stopped") {
status.classList.add("live-status--stopped");
text.textContent = "Live updates paused";
status.title = "Live updates stopped. Refresh the page to resume.";
} else {
text.textContent = liveUpdates.newSinceLoad > 0
? "Live · " + liveUpdates.newSinceLoad + " new"
: "Live";
status.title =
"New check-ins appear automatically (checked every " +
LIVE_POLL_INTERVAL_MS / 1000 +
" seconds, paused while this tab is hidden). Last checked " +
new Date().toLocaleTimeString() + ".";
}
}