1511 lines
49 KiB
JavaScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
// 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 & 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() + ".";
|
|
}
|
|
} |