You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
880 lines
42 KiB
880 lines
42 KiB
<!doctype html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Attendance Dashboard</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js" crossorigin="anonymous"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
|
|
<style>
|
|
.spinner-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 200px;
|
|
}
|
|
|
|
.spinner-border {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
color: #007bff;
|
|
}
|
|
|
|
.zone-summary-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 20px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.zone-summary-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.zone-summary-card {
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
padding: 15px;
|
|
background-color: #f9f9f9;
|
|
}
|
|
|
|
.disabled-count {
|
|
color: #6c757d;
|
|
cursor: default;
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
padding: 0 4px;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body class="p-3">
|
|
<div class="container">
|
|
<h1 class="mb-4">Attendance Dashboard</h1>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-md-3">
|
|
<label class="form-label">From</label>
|
|
<input id="fromDate" type="date" class="form-control" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">To</label>
|
|
<input id="toDate" type="date" class="form-control" />
|
|
</div>
|
|
<div class="col-md-6 d-flex align-items-end">
|
|
<div>
|
|
<button id="loadSummaryBtn" class="btn btn-secondary">Load</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h4>Abstract Counts</h4>
|
|
<div id="abstractCounts" class="mb-4"></div>
|
|
|
|
<h4>Zone Wise Attendance %</h4>
|
|
<div id="zoneWisePercentage" class="mb-4"></div>
|
|
|
|
<h4>Zone Summary</h4>
|
|
<div class="zone-summary-row">
|
|
<div class="zone-summary-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h5>Park</h5>
|
|
<button class="btn btn-sm btn-primary"
|
|
onclick="downloadExcelFromTable(document.getElementById('zoneSummaryPark'), 'park_summary.xlsx')">
|
|
<i class="bi bi-download"></i> Download Excel
|
|
</button>
|
|
</div>
|
|
<div id="zoneSummaryPark"></div>
|
|
</div>
|
|
<!-- NULM zone summary card commented out
|
|
<div class="zone-summary-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h5>NULM</h5>
|
|
<button class="btn btn-sm btn-primary"
|
|
onclick="downloadExcelFromTable(document.getElementById('zoneSummaryNulm'), 'nulm_summary.xlsx')">
|
|
<i class="bi bi-download"></i> Download Excel
|
|
</button>
|
|
</div>
|
|
<div id="zoneSummaryNulm"></div>
|
|
</div>
|
|
-->
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
|
|
function showImageModal(imageSrc) {
|
|
document.getElementById("previewImage").src = imageSrc;
|
|
var modal = new bootstrap.Modal(document.getElementById('imagePreviewModal'));
|
|
modal.show();
|
|
}
|
|
|
|
(function () {
|
|
const apiBase = '/commissionerreview/api/attendanceController';
|
|
|
|
function pad(n) { return n < 10 ? '0' + n : n; }
|
|
|
|
// convert Date -> yyyy-mm-dd for input[type=date]
|
|
function toIsoYyyyMmDd(date) {
|
|
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
|
|
}
|
|
|
|
// convert input value (either yyyy-mm-dd or dd-mm-yyyy) -> dd-mm-yyyy for API
|
|
function toApiDdMmYyyy(val) {
|
|
if (!val) return val;
|
|
// if already in dd-mm-yyyy (contains two '-') and first part length 2
|
|
const parts = val.split('-');
|
|
if (parts.length === 3) {
|
|
if (parts[0].length === 4) {
|
|
// yyyy-mm-dd -> dd-mm-yyyy
|
|
return pad(parseInt(parts[2], 10)) + '-' + pad(parseInt(parts[1], 10)) + '-' + parts[0];
|
|
} else {
|
|
// assume already dd-mm-yyyy
|
|
return val;
|
|
}
|
|
}
|
|
return val;
|
|
}
|
|
|
|
function setDefaults() {
|
|
const today = new Date();
|
|
const iso = toIsoYyyyMmDd(today);
|
|
document.getElementById('fromDate').value = iso;
|
|
document.getElementById('toDate').value = iso;
|
|
}
|
|
|
|
function setDateLimits() {
|
|
const from = document.getElementById('fromDate');
|
|
const to = document.getElementById('toDate');
|
|
const todayIso = toIsoYyyyMmDd(new Date());
|
|
from.max = todayIso;
|
|
to.max = todayIso;
|
|
if (from.value) to.min = from.value;
|
|
|
|
from.addEventListener('change', () => {
|
|
const fv = from.value;
|
|
if (fv) {
|
|
to.min = fv;
|
|
if (to.value && to.value < fv) to.value = fv;
|
|
} else {
|
|
to.min = '';
|
|
}
|
|
});
|
|
|
|
to.addEventListener('change', () => {
|
|
if (from.value && to.value && to.value < from.value) {
|
|
to.value = from.value;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadAbstractCounts() {
|
|
const target = document.getElementById('abstractCounts');
|
|
target.innerHTML = createLoader();
|
|
try {
|
|
const res = await fetch(apiBase + '/abstractCounts');
|
|
const data = await res.json();
|
|
if (!Array.isArray(data)) { target.innerText = JSON.stringify(data); return; }
|
|
let html = '<div class="row">';
|
|
data.forEach(item => {
|
|
const facialRegistered = item.facialRegistered || 0;
|
|
const facialNotRegistered = item.facialNotRegistered || 0;
|
|
const totalCount = item.totalCount || 0;
|
|
const facialRegPct = totalCount > 0 ? ((facialRegistered / totalCount) * 100).toFixed(2) : '0.00';
|
|
html += `<div class="col-md-3"><div class="card p-3 mb-2">
|
|
<strong>${item.emp_type}</strong>
|
|
<div class="mt-2">
|
|
<div class="small">Total: <strong>${totalCount}</strong></div>
|
|
<div class="small">Facial Registered: <strong>${facialRegistered}</strong></div>
|
|
<div class="small">Facial Not Registered: <strong>${facialNotRegistered}</strong></div>
|
|
<div class="small">Facial Registered %: <strong>${facialRegPct}%</strong></div>
|
|
<div class="small">Present %: <strong><span id="presentPercent">—</span></strong></div>
|
|
</div>
|
|
</div></div>`;
|
|
});
|
|
html += '</div>';
|
|
target.innerHTML = html;
|
|
} catch (e) { target.innerHTML = `<div class="alert alert-danger">Error: ${e}</div>`; }
|
|
}
|
|
|
|
async function loadZoneWisePercentage() {
|
|
const target = document.getElementById('zoneWisePercentage');
|
|
target.innerHTML = createLoader();
|
|
try {
|
|
const rawFrom = document.getElementById('fromDate').value;
|
|
const rawTo = document.getElementById('toDate').value;
|
|
const fromDate = toApiDdMmYyyy(rawFrom);
|
|
const toDate = toApiDdMmYyyy(rawTo);
|
|
const res = await fetch(`${apiBase}/zoneWisePercentage?fromDate=${encodeURIComponent(fromDate)}&toDate=${encodeURIComponent(toDate)}`);
|
|
const data = await res.json();
|
|
if (!Array.isArray(data) || data.length === 0) {
|
|
target.innerHTML = '<div class="alert alert-info">No zone percentage data available</div>';
|
|
return;
|
|
}
|
|
let html = '<div class="row">';
|
|
data.forEach(item => {
|
|
const zoneName = item.ZoneName || 'Unknown Zone';
|
|
const facialPct = item.facialPercentage != null ? item.facialPercentage : 0;
|
|
const presentPct = item.presentPercentage != null ? item.presentPercentage : 0;
|
|
html += `<div class="col-md-4"><div class="card p-3 mb-2 text-center">
|
|
<strong>${zoneName}</strong>
|
|
<div class="row mt-2">
|
|
<div class="col-6">
|
|
<div style="font-size: 1.3rem; font-weight: bold;">
|
|
${facialPct}%
|
|
</div>
|
|
<div class="small text-muted">Facial Registered %</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div style="font-size: 1.3rem; font-weight: bold;">
|
|
${presentPct}%
|
|
</div>
|
|
<div class="small text-muted">Present %</div>
|
|
</div>
|
|
</div>
|
|
</div></div>`;
|
|
});
|
|
html += '</div>';
|
|
target.innerHTML = html;
|
|
} catch (e) {
|
|
target.innerHTML = `<div class="alert alert-danger">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
function createLoader() {
|
|
return '<div class="spinner-container"><div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div></div>';
|
|
}
|
|
|
|
function downloadExcelFromTable(tableElement, fileName) {
|
|
if (typeof XLSX === 'undefined') {
|
|
alert('Excel library is not loaded. Please refresh the page and try again.');
|
|
console.error('XLSX library failed to load from CDN');
|
|
return;
|
|
}
|
|
if (!tableElement) {
|
|
alert('Table not found');
|
|
return;
|
|
}
|
|
const table = tableElement.querySelector('table');
|
|
if (!table) {
|
|
alert('No table to download');
|
|
return;
|
|
}
|
|
const workbook = XLSX.utils.table_to_book(table);
|
|
XLSX.writeFile(workbook, fileName);
|
|
}
|
|
|
|
// Expose to global scope for onclick handlers
|
|
window.downloadExcelFromTable = downloadExcelFromTable;
|
|
|
|
function renderTable(target, data, empType) {
|
|
if (!Array.isArray(data)) { target.innerText = JSON.stringify(data); return; }
|
|
if (data.length === 0) { target.innerHTML = '<div class="alert alert-info">No records</div>'; return; }
|
|
const rows = data.slice().sort((a, b) => String(a.zone || '').localeCompare(String(b.zone || ''), undefined, { numeric: true }));
|
|
// let html = '<table class="table table-sm table-striped"><thead><tr><th>Zone</th><th class="text-center">Total</th><th class="text-center">Facial Registered</th><th class="text-center">Facial Not Registered</th><th class="text-center">Supervisor</th><th class="text-center">Present</th><th class="text-center">OD</th><th class="text-center">Leave</th><th class="text-center">Not Marked</th></tr></thead><tbody>';
|
|
let html = '<table class="table table-sm table-striped"><thead><tr><th>Zone</th><th class="text-center">Total</th><th class="text-center">Facial Registered</th><th class="text-center">Facial Not Registered</th><th class="text-center">Present</th><th class="text-center">OD</th><th class="text-center">Leave</th><th class="text-center">Not Marked</th></tr></thead><tbody>';
|
|
rows.forEach(r => {
|
|
const zone = r.zone;
|
|
const total = Number(r.TotalCount) || 0;
|
|
const facialRegistered = Number(r.facialRegistered) || 0;
|
|
const facialNotRegistered = Number(r.facialNotRegistered) || 0;
|
|
const present = Number(r.AttendanceMarked) || 0;
|
|
const od = Number(r.ODCount) || 0;
|
|
const leave = Number(r.LeaveCount) || 0;
|
|
const notMarked = Math.max(0, total - present - od - leave - facialNotRegistered);
|
|
// const supervisor = Number(r.SupervisorCount) || 0;
|
|
|
|
html += `<tr><td>${zone || '-'}</td>`;
|
|
html += `<td class="text-center">` + (total > 0
|
|
? `<a href="#" class="zone-count" data-zone="${zone}" data-emptype="${empType}" data-category="total">${total}</a>`
|
|
: `<span class="disabled-count">${total}</span>`) + `</td>`;
|
|
html += `<td class="text-center">${facialRegistered}</td>`;
|
|
html += `<td class="text-center">` + (facialNotRegistered > 0
|
|
? `<a href="#" class="zone-count" data-zone="${zone}" data-emptype="${empType}" data-category="notregistered">${facialNotRegistered}</a>`
|
|
: `<span class="disabled-count">${facialNotRegistered}</span>`) + `</td>`;
|
|
// html += `<td class="text-center">` + (supervisor > 0
|
|
// ? `<a href="#" class="zone-count" data-zone="${zone}" data-emptype="${empType}" data-category="supervisor">${supervisor}</a>`
|
|
// : `<span class="disabled-count">${supervisor}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (present > 0
|
|
? `<a href="#" class="zone-count" data-zone="${zone}" data-emptype="${empType}" data-category="present">${present}</a>`
|
|
: `<span class="disabled-count">${present}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (od > 0
|
|
? `<a href="#" class="zone-count" data-zone="${zone}" data-emptype="${empType}" data-category="od">${od}</a>`
|
|
: `<span class="disabled-count">${od}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (leave > 0
|
|
? `<a href="#" class="zone-count" data-zone="${zone}" data-emptype="${empType}" data-category="leave">${leave}</a>`
|
|
: `<span class="disabled-count">${leave}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (notMarked > 0
|
|
? `<a href="#" class="zone-count" data-zone="${zone}" data-emptype="${empType}" data-category="notmarked">${notMarked}</a>`
|
|
: `<span class="disabled-count">${notMarked}</span>`) + `</td>`;
|
|
html += '</tr>';
|
|
});
|
|
|
|
// Compute grand totals
|
|
let grandTotal = 0, grandSupervisor = 0, grandFacialReg = 0, grandFacialNotReg = 0, grandPresent = 0, grandOd = 0, grandLeave = 0, grandNotMarked = 0;
|
|
rows.forEach(r => {
|
|
const total = Number(r.TotalCount) || 0;
|
|
const facialRegistered = Number(r.facialRegistered) || 0;
|
|
const facialNotRegistered = Number(r.facialNotRegistered) || 0;
|
|
const present = Number(r.AttendanceMarked) || 0;
|
|
const od = Number(r.ODCount) || 0;
|
|
const leave = Number(r.LeaveCount) || 0;
|
|
grandTotal += total;
|
|
// grandSupervisor += (Number(r.SupervisorCount) || 0);
|
|
grandFacialReg += facialRegistered;
|
|
grandFacialNotReg += facialNotRegistered;
|
|
grandPresent += present;
|
|
grandOd += od;
|
|
grandLeave += leave;
|
|
grandNotMarked += Math.max(0, total - present - od - leave - facialNotRegistered);
|
|
});
|
|
html += '</tbody><tfoot><tr style="font-weight:bold; background-color:#e9ecef;">';
|
|
html += '<td>Total</td>';
|
|
html += `<td class="text-center">${grandTotal}</td>`;
|
|
html += `<td class="text-center">${grandFacialReg}</td>`;
|
|
html += `<td class="text-center">${grandFacialNotReg}</td>`;
|
|
// html += `<td class="text-center">${grandSupervisor}</td>`;
|
|
html += `<td class="text-center">${grandPresent}</td>`;
|
|
html += `<td class="text-center">${grandOd}</td>`;
|
|
html += `<td class="text-center">${grandLeave}</td>`;
|
|
html += `<td class="text-center">${grandNotMarked}</td>`;
|
|
html += '</tr></tfoot></table>';
|
|
target.innerHTML = html;
|
|
}
|
|
|
|
async function loadZoneSummary() {
|
|
const rawFrom = document.getElementById('fromDate').value;
|
|
const rawTo = document.getElementById('toDate').value;
|
|
const fromDate = toApiDdMmYyyy(rawFrom);
|
|
const toDate = toApiDdMmYyyy(rawTo);
|
|
const parkTarget = document.getElementById('zoneSummaryPark');
|
|
// const nulmTarget = document.getElementById('zoneSummaryNulm'); // NULM commented out
|
|
|
|
// Show loaders
|
|
parkTarget.innerHTML = createLoader();
|
|
// nulmTarget.innerHTML = createLoader(); // NULM commented out
|
|
|
|
try {
|
|
// Load Park independently
|
|
fetch(`${apiBase}/zoneSummaryPark?fromDate=${encodeURIComponent(fromDate)}&toDate=${encodeURIComponent(toDate)}`)
|
|
.then(res => {
|
|
if (!res.ok) {
|
|
console.error('Park zoneSummary error', res.status);
|
|
parkTarget.innerHTML = `<div class="alert alert-danger">Error loading Park summary (${res.status})</div>`;
|
|
return Promise.reject(new Error('Park fetch failed'));
|
|
}
|
|
return res.json();
|
|
})
|
|
.then(parkData => {
|
|
console.log('Park data loaded:', parkData);
|
|
renderTable(parkTarget, parkData, 'Park');
|
|
// Compute Present % (Present + OD) / Total
|
|
if (Array.isArray(parkData) && parkData.length > 0) {
|
|
let facialRegisteredAll = 0, presentOdAll = 0;
|
|
parkData.forEach(r => {
|
|
facialRegisteredAll += (Number(r.facialRegistered) || 0);
|
|
presentOdAll += (Number(r.AttendanceMarked) || 0) + (Number(r.ODCount) || 0);
|
|
});
|
|
const pct = facialRegisteredAll > 0 ? ((presentOdAll / facialRegisteredAll) * 100).toFixed(1) : '0.0';
|
|
const el = document.getElementById('presentPercent');
|
|
if (el) el.textContent = pct + '%';
|
|
}
|
|
})
|
|
.catch(e => {
|
|
console.error('Park error:', e);
|
|
parkTarget.innerHTML = `<div class="alert alert-danger">Error: ${e.message}</div>`;
|
|
});
|
|
|
|
// NULM loading commented out
|
|
// fetch(`${apiBase}/zoneSummaryNULM?fromDate=${encodeURIComponent(fromDate)}&toDate=${encodeURIComponent(toDate)}`)
|
|
// .then(res => {
|
|
// if (!res.ok) {
|
|
// console.error('NULM zoneSummary error', res.status);
|
|
// nulmTarget.innerHTML = `<div class="alert alert-danger">Error loading NULM summary (${res.status})</div>`;
|
|
// return Promise.reject(new Error('NULM fetch failed'));
|
|
// }
|
|
// return res.json();
|
|
// })
|
|
// .then(nulmData => {
|
|
// console.log('NULM data loaded:', nulmData);
|
|
// renderTable(nulmTarget, nulmData, 'NULM');
|
|
// })
|
|
// .catch(e => {
|
|
// console.error('NULM error:', e);
|
|
// nulmTarget.innerHTML = `<div class="alert alert-danger">Error: ${e.message}</div>`;
|
|
// });
|
|
} catch (e) {
|
|
parkTarget.innerHTML = `<div class="alert alert-danger">Error: ${e}</div>`;
|
|
// nulmTarget.innerHTML = `<div class="alert alert-danger">Error: ${e}</div>`; // NULM commented out
|
|
}
|
|
}
|
|
|
|
async function loadParkWiseSummary(zone, category) {
|
|
if (!zone) {
|
|
alert('Please select a zone first');
|
|
return;
|
|
}
|
|
const modalEl = document.getElementById('parkWiseSummaryModal');
|
|
const modalBody = document.getElementById('parkWiseSummaryModalBody');
|
|
const modalTitle = document.getElementById('parkWiseSummaryModalLabel');
|
|
modalTitle.innerText = `Park-wise Summary — Zone ${zone} — ${(category || 'All').toUpperCase()}`;
|
|
modalBody.innerHTML = createLoader();
|
|
new bootstrap.Modal(modalEl).show();
|
|
try {
|
|
const rawFrom = document.getElementById('fromDate').value;
|
|
const rawTo = document.getElementById('toDate').value;
|
|
const fromDate = toApiDdMmYyyy(rawFrom);
|
|
const toDate = toApiDdMmYyyy(rawTo);
|
|
const url = `${apiBase}/parkWiseSummary?fromDate=${encodeURIComponent(fromDate)}&toDate=${encodeURIComponent(toDate)}&zone=${encodeURIComponent(zone)}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
modalBody.innerHTML = `<div class="alert alert-danger">Error loading park-wise summary (${res.status})</div>`;
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (!Array.isArray(data)) { modalBody.innerText = JSON.stringify(data); return; }
|
|
if (data.length === 0) { modalBody.innerHTML = '<div class="alert alert-info">No park-wise records</div>'; return; }
|
|
|
|
// Filter parks: when a category is selected, only show parks with count > 0 for that category
|
|
const catLower = (category || '').toLowerCase();
|
|
const displayData = !catLower ? data : data.filter(r => {
|
|
const total = Number(r.TotalCount) || 0;
|
|
const present = Number(r.AttendanceMarked) || 0;
|
|
const od = Number(r.ODCount) || 0;
|
|
const leave = Number(r.LeaveCount) || 0;
|
|
const facialNotRegistered = Number(r.facialNotRegistered) || 0;
|
|
const notMarked = Math.max(0, total - present - od - leave - facialNotRegistered);
|
|
if (catLower === 'total') return total > 0;
|
|
if (catLower === 'present') return present > 0;
|
|
if (catLower === 'od') return od > 0;
|
|
if (catLower === 'leave') return leave > 0;
|
|
if (catLower === 'notmarked') return notMarked > 0;
|
|
if (catLower === 'notregistered') return facialNotRegistered > 0;
|
|
return true;
|
|
});
|
|
|
|
if (displayData.length === 0) { modalBody.innerHTML = '<div class="alert alert-info">No parks with ' + (category || '').toUpperCase() + ' count</div>'; return; }
|
|
|
|
const showAll = !catLower;
|
|
|
|
let html = '<div class="table-responsive"><table class="table table-sm table-striped"><thead><tr>';
|
|
html += '<th style="display:none;">Park ID</th><th>Park Name</th><th>Supervisor Name</th><th>Supervisor Mobile</th>';
|
|
if (showAll) {
|
|
html += '<th class="text-center">Total</th><th class="text-center">Facial Registered</th><th class="text-center">Facial Not Registered</th><th class="text-center">Present</th><th class="text-center">OD</th><th class="text-center">Leave</th><th class="text-center">Not Marked</th>';
|
|
} else {
|
|
const categoryLabel = { present: 'Present', od: 'OD', leave: 'Leave', notmarked: 'Not Marked', notregistered: 'Facial Not Registered' };
|
|
html += `<th class="text-center">${categoryLabel[catLower] || category}</th>`;
|
|
}
|
|
html += '</tr></thead><tbody>';
|
|
displayData.forEach(r => {
|
|
const parkId = r.park_id || '';
|
|
const park = r.park_name || '';
|
|
const parkDisplay = park === '' ? (parkId || 'Unknown') : park;
|
|
const supervisorName = r.supervisor_name || '-';
|
|
const supervisorMobile = r.supervisor_mobile || '-';
|
|
const total = Number(r.TotalCount) || 0;
|
|
const facialRegistered = Number(r.facialRegistered) || 0;
|
|
const facialNotRegistered = Number(r.facialNotRegistered) || 0;
|
|
const present = Number(r.AttendanceMarked) || 0;
|
|
const od = Number(r.ODCount) || 0;
|
|
const leave = Number(r.LeaveCount) || 0;
|
|
const notMarked = Math.max(0, total - present - od - leave - facialNotRegistered);
|
|
html += '<tr>';
|
|
html += `<td style="display:none;">${parkId}</td>`;
|
|
html += `<td>${parkDisplay}</td>`;
|
|
html += `<td>${supervisorName}</td>`;
|
|
html += `<td>${supervisorMobile}</td>`;
|
|
if (showAll) {
|
|
html += `<td class="text-center">${total}</td>`;
|
|
html += `<td class="text-center">${facialRegistered}</td>`;
|
|
html += `<td class="text-center">` + (facialNotRegistered > 0 ? `<a href="#" class="park-employee-count" data-zone="${zone}" data-parkid="${parkId}" data-parkname="${parkDisplay}" data-category="notregistered">${facialNotRegistered}</a>` : `<span class="disabled-count">${facialNotRegistered}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (present > 0 ? `<a href="#" class="park-employee-count" data-zone="${zone}" data-parkid="${parkId}" data-parkname="${parkDisplay}" data-category="present">${present}</a>` : `<span class="disabled-count">${present}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (od > 0 ? `<a href="#" class="park-employee-count" data-zone="${zone}" data-parkid="${parkId}" data-parkname="${parkDisplay}" data-category="od">${od}</a>` : `<span class="disabled-count">${od}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (leave > 0 ? `<a href="#" class="park-employee-count" data-zone="${zone}" data-parkid="${parkId}" data-parkname="${parkDisplay}" data-category="leave">${leave}</a>` : `<span class="disabled-count">${leave}</span>`) + `</td>`;
|
|
html += `<td class="text-center">` + (notMarked > 0 ? `<a href="#" class="park-employee-count" data-zone="${zone}" data-parkid="${parkId}" data-parkname="${parkDisplay}" data-category="notmarked">${notMarked}</a>` : `<span class="disabled-count">${notMarked}</span>`) + `</td>`;
|
|
} else {
|
|
// Show only the selected category column
|
|
let catValue = 0;
|
|
if (catLower === 'present') catValue = present;
|
|
else if (catLower === 'od') catValue = od;
|
|
else if (catLower === 'leave') catValue = leave;
|
|
else if (catLower === 'notmarked') catValue = notMarked;
|
|
else if (catLower === 'notregistered') catValue = facialNotRegistered;
|
|
html += `<td class="text-center">` + (catValue > 0 ? `<a href="#" class="park-employee-count" data-zone="${zone}" data-parkid="${parkId}" data-parkname="${parkDisplay}" data-category="${catLower}">${catValue}</a>` : `<span class="disabled-count">${catValue}</span>`) + `</td>`;
|
|
}
|
|
html += '</tr>';
|
|
});
|
|
|
|
// Compute grand totals from ALL data to match zone summary (no count mismatch)
|
|
let grandTotal = 0, grandFacialReg = 0, grandFacialNotReg = 0, grandPresent = 0, grandOd = 0, grandLeave = 0, grandNotMarked = 0;
|
|
data.forEach(r => {
|
|
const total = Number(r.TotalCount) || 0;
|
|
const facialRegistered = Number(r.facialRegistered) || 0;
|
|
const facialNotRegistered = Number(r.facialNotRegistered) || 0;
|
|
const present = Number(r.AttendanceMarked) || 0;
|
|
const od = Number(r.ODCount) || 0;
|
|
const leave = Number(r.LeaveCount) || 0;
|
|
grandTotal += total;
|
|
grandFacialReg += facialRegistered;
|
|
grandFacialNotReg += facialNotRegistered;
|
|
grandPresent += present;
|
|
grandOd += od;
|
|
grandLeave += leave;
|
|
grandNotMarked += Math.max(0, total - present - od - leave - facialNotRegistered);
|
|
});
|
|
html += '</tbody><tfoot><tr style="font-weight:bold; background-color:#e9ecef;">';
|
|
html += '<td>Total</td>';
|
|
html += '<td></td>';
|
|
html += '<td></td>';
|
|
if (showAll) {
|
|
html += `<td class="text-center">${grandTotal}</td>`;
|
|
html += `<td class="text-center">${grandFacialReg}</td>`;
|
|
html += `<td class="text-center">${grandFacialNotReg}</td>`;
|
|
html += `<td class="text-center">${grandPresent}</td>`;
|
|
html += `<td class="text-center">${grandOd}</td>`;
|
|
html += `<td class="text-center">${grandLeave}</td>`;
|
|
html += `<td class="text-center">${grandNotMarked}</td>`;
|
|
} else {
|
|
let grandCatValue = 0;
|
|
if (catLower === 'present') grandCatValue = grandPresent;
|
|
else if (catLower === 'od') grandCatValue = grandOd;
|
|
else if (catLower === 'leave') grandCatValue = grandLeave;
|
|
else if (catLower === 'notmarked') grandCatValue = grandNotMarked;
|
|
else if (catLower === 'notregistered') grandCatValue = grandFacialNotReg;
|
|
html += `<td class="text-center">${grandCatValue}</td>`;
|
|
}
|
|
html += '</tr></tfoot></table></div>';
|
|
modalBody.innerHTML = html;
|
|
} catch (e) {
|
|
modalBody.innerHTML = `<div class="alert alert-danger">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadParkDetails(zone, type) {
|
|
const modalEl = document.getElementById('detailModal');
|
|
const modalBody = document.getElementById('detailModalBody');
|
|
const modalTitle = document.getElementById('detailModalLabel');
|
|
modalTitle.innerText = `Parks — Zone ${zone} — ${type}`;
|
|
modalBody.innerHTML = createLoader();
|
|
new bootstrap.Modal(modalEl).show();
|
|
try {
|
|
const url = `/commissionerreview/api/parks/getParkDataByZone?zone=${encodeURIComponent(zone)}&type=${encodeURIComponent(type)}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
modalBody.innerHTML = `<div class="alert alert-danger">Error ${res.status}</div>`;
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (!Array.isArray(data)) { modalBody.innerText = JSON.stringify(data); return; }
|
|
if (data.length === 0) { modalBody.innerHTML = '<div class="alert alert-info">No records</div>'; return; }
|
|
|
|
// Build table from keys of first row
|
|
const cols = Object.keys(data[0]);
|
|
let html = '<div class="table-responsive"><table class="table table-sm table-bordered"><thead><tr><th>S.No</th>';
|
|
cols.forEach(c => html += `<th>${c.replace(/_/g, ' ')}</th>`);
|
|
html += '</tr></thead><tbody>';
|
|
data.forEach((row, idx) => {
|
|
html += '<tr>';
|
|
html += `<td>${idx + 1}</td>`;
|
|
cols.forEach(c => html += `<td>${row[c] != null ? row[c] : '-'}</td>`);
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table></div>';
|
|
modalBody.innerHTML = html;
|
|
} catch (e) {
|
|
modalBody.innerHTML = `<div class="alert alert-danger">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
// loadDetailed may be called with explicit empType/category/zone (when clicking a zone count)
|
|
async function loadDetailed(empType, category, zoneVal, parkId, parkName) {
|
|
// show details in modal
|
|
const rawFrom = document.getElementById('fromDate').value;
|
|
const rawTo = document.getElementById('toDate').value;
|
|
const fromDate = toApiDdMmYyyy(rawFrom);
|
|
const toDate = toApiDdMmYyyy(rawTo);
|
|
const modalEl = document.getElementById('detailModal');
|
|
const modalBody = document.getElementById('detailModalBody');
|
|
const modalTitle = document.getElementById('detailModalLabel');
|
|
if (!empType || !category || !zoneVal) {
|
|
modalTitle.innerText = 'Details';
|
|
modalBody.innerHTML = '<div class="text-warning">Missing parameters. Click a count to view details.</div>';
|
|
new bootstrap.Modal(modalEl).show();
|
|
return;
|
|
}
|
|
|
|
let titleSuffix = parkName ? ` — ${parkName} - ${parkId}` : '';
|
|
modalTitle.innerText = `${empType} — Zone ${zoneVal}${titleSuffix} — ${category.toUpperCase()} (${rawFrom} → ${rawTo})`;
|
|
modalBody.innerHTML = createLoader();
|
|
// Show modal immediately with loader
|
|
new bootstrap.Modal(modalEl).show();
|
|
|
|
try {
|
|
let url = `${apiBase}/detailed?empType=${encodeURIComponent(empType)}&category=${encodeURIComponent(category)}&fromDate=${encodeURIComponent(fromDate)}&toDate=${encodeURIComponent(toDate)}`;
|
|
url += `&zone=${encodeURIComponent(zoneVal)}`;
|
|
if (parkId) {
|
|
url += `&parkId=${encodeURIComponent(parkId)}`;
|
|
}
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
modalBody.innerHTML = `<div class="alert alert-danger">Error ${res.status}: ${text}</div>`;
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (!Array.isArray(data)) { modalBody.innerText = JSON.stringify(data); return; }
|
|
if (data.length === 0) { modalBody.innerHTML = '<div class="alert alert-info">No records</div>'; return; }
|
|
|
|
// Define columns to display based on category
|
|
let displayColumns = [];
|
|
const catLower = (category || 'present').toLowerCase();
|
|
|
|
if (catLower === 'total') {
|
|
displayColumns = ['park_name', 'supervisor_name', 'supervisor_mobile', 'enrollment_id', 'name', 'emp_mobile'];
|
|
} else if (catLower === 'present') {
|
|
displayColumns = ['enrollment_id', 'name', 'designation', 'department', 'incharge_name', 'incharge_phoneno', 'App Version', 'App Type', 'Location'];
|
|
if (empType === 'Park') displayColumns.push('park_name');
|
|
displayColumns.push('intime', 'inphoto', 'outtime', 'outphoto');
|
|
} else if (catLower === 'leave') {
|
|
displayColumns = ['enrollment_id', 'name', 'designation', 'department', 'incharge_name', 'incharge_phoneno', 'App Version', 'App Type', 'Location'];
|
|
if (empType === 'Park') displayColumns.push('park_name');
|
|
displayColumns.push('leavedatetime', 'leaveby');
|
|
} else if (catLower === 'od') {
|
|
displayColumns = ['enrollment_id', 'name', 'designation', 'department', 'incharge_name', 'incharge_phoneno', 'App Version', 'App Type', 'Location'];
|
|
if (empType === 'Park') displayColumns.push('park_name');
|
|
displayColumns.push('odtime', 'odby');
|
|
} else if (catLower === 'notmarked') {
|
|
displayColumns = ['enrollment_id', 'name', 'designation', 'department', 'incharge_name', 'incharge_phoneno', 'image'];
|
|
// if (empType === 'Park') displayColumns.push('park_name');
|
|
} else if (catLower === 'notregistered') {
|
|
displayColumns = ['enrollment_id', 'name', 'designation', 'department', 'incharge_name', 'incharge_phoneno'];
|
|
// if (empType === 'Park') displayColumns.push('park_name');
|
|
} else if (catLower === 'supervisor') {
|
|
displayColumns = ['supervisor_id', 'name', 'designation', 'department', 'incharge_name', 'incharge_phoneno'];
|
|
} else {
|
|
displayColumns = Object.keys(data[0]);
|
|
}
|
|
|
|
// Filter data to include only displayColumns, handle case-insensitive matching
|
|
const filteredData = data.map(row => {
|
|
const filtered = {};
|
|
displayColumns.forEach(col => {
|
|
// Try exact match first, then case-insensitive
|
|
const key = Object.keys(row).find(k => k === col || k.toLowerCase() === col.toLowerCase());
|
|
if (key) {
|
|
filtered[col] = row[key];
|
|
}
|
|
});
|
|
return filtered;
|
|
});
|
|
|
|
function prettyName(key) {
|
|
const map = {
|
|
enrollment_id: 'Enrollment ID',
|
|
name: 'Name',
|
|
designation: 'Designation',
|
|
department: 'Department',
|
|
park_name: 'Park Name',
|
|
intime: 'In Time',
|
|
inphoto: 'In Photo',
|
|
outtime: 'Out Time',
|
|
outphoto: 'Out Photo',
|
|
leavedatetime: 'Leave DateTime',
|
|
leaveby: 'Leave By',
|
|
odtime: 'OD DateTime',
|
|
odby: 'OD By',
|
|
incharge_name: 'Incharge Name',
|
|
incharge_phoneno: 'Incharge Contact',
|
|
appversion: 'App Version',
|
|
apptype: 'App Type',
|
|
location: 'Location',
|
|
image: 'Image',
|
|
supervisor_id: 'Supervisor ID',
|
|
supervisor_name: 'Supervisor Name',
|
|
supervisor_mobile: 'Supervisor Mobile',
|
|
emp_mobile: 'Employee Mobile',
|
|
};
|
|
if (map[key]) return map[key];
|
|
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
}
|
|
|
|
let html = '<div class="table-responsive"><table class="table table-sm table-bordered"><thead><tr>';
|
|
// add serial number header
|
|
html += '<th>S.No</th>';
|
|
displayColumns.forEach(k => {
|
|
html += `<th>${prettyName(k)}</th>`;
|
|
});
|
|
html += '</tr></thead><tbody>';
|
|
filteredData.forEach((row, idx) => {
|
|
html += '<tr>';
|
|
// serial number cell
|
|
html += `<td>${idx + 1}</td>`;
|
|
displayColumns.forEach(k => {
|
|
const val = row[k];
|
|
// Handle photo columns - show as thumbnail if it's an image
|
|
// if ((k === 'inphoto' || k === 'outphoto') && val) {
|
|
// html += `<td><img src="${val}" alt="photo" style="max-width: 100px; max-height: 100px; cursor: pointer;" onclick="this.style.maxWidth='500px'; this.style.maxHeight='500px';"></td>`;
|
|
// } else {
|
|
// html += `<td>${val != null ? val : '-'}</td>`;
|
|
// }
|
|
|
|
if ((k === 'inphoto' || k === 'outphoto' || k === 'image') && val) {
|
|
html += `<td>
|
|
<img src="${val}" alt="photo"
|
|
style="max-width: 100px; max-height: 100px; cursor: pointer;"
|
|
onclick="showImageModal('${val}')">
|
|
</td>`;
|
|
} else {
|
|
html += `<td>${val != null ? val : '-'}</td>`;
|
|
}
|
|
});
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table></div>';
|
|
modalBody.innerHTML = html;
|
|
} catch (e) {
|
|
modalBody.innerHTML = `<div class="alert alert-danger">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
setDefaults();
|
|
document.getElementById('loadSummaryBtn').addEventListener('click', () => {
|
|
const rawFrom = document.getElementById('fromDate').value;
|
|
const rawTo = document.getElementById('toDate').value;
|
|
if (rawFrom && rawTo && rawTo < rawFrom) {
|
|
alert('To date must be the same as or later than From date');
|
|
return;
|
|
}
|
|
loadAbstractCounts(); loadZoneWisePercentage(); loadZoneSummary();
|
|
});
|
|
// delegate clicks: zone links -> park-wise summary, zone counts -> detailed, park counts -> park-details
|
|
document.addEventListener('click', (ev) => {
|
|
const zoneLink = ev.target.closest && ev.target.closest('.zone-link');
|
|
if (zoneLink) {
|
|
ev.preventDefault();
|
|
const zone = zoneLink.getAttribute('data-zone');
|
|
loadParkWiseSummary(zone);
|
|
return;
|
|
}
|
|
|
|
const zoneAnchor = ev.target.closest && ev.target.closest('.zone-count');
|
|
if (zoneAnchor) {
|
|
ev.preventDefault();
|
|
const zone = zoneAnchor.getAttribute('data-zone');
|
|
const empType = zoneAnchor.getAttribute('data-emptype');
|
|
const category = zoneAnchor.getAttribute('data-category');
|
|
// Supervisor & Total: show employee details directly (no park-wise drill-down)
|
|
if (category === 'supervisor' || category === 'total') {
|
|
loadDetailed(empType, category, zone);
|
|
} else if (empType === 'Park') {
|
|
loadParkWiseSummary(zone, category);
|
|
}
|
|
// NULM branch commented out
|
|
// else {
|
|
// loadDetailed(empType, category, zone);
|
|
// }
|
|
return;
|
|
}
|
|
|
|
const parkAnchor = ev.target.closest && ev.target.closest('.park-count');
|
|
if (parkAnchor) {
|
|
ev.preventDefault();
|
|
const zone = parkAnchor.getAttribute('data-zone');
|
|
const type = parkAnchor.getAttribute('data-type');
|
|
loadParkDetails(zone, type);
|
|
return;
|
|
}
|
|
|
|
const parkEmployeeAnchor = ev.target.closest && ev.target.closest('.park-employee-count');
|
|
if (parkEmployeeAnchor) {
|
|
ev.preventDefault();
|
|
const zone = parkEmployeeAnchor.getAttribute('data-zone');
|
|
const parkId = parkEmployeeAnchor.getAttribute('data-parkid');
|
|
const parkName = parkEmployeeAnchor.getAttribute('data-parkname');
|
|
const category = parkEmployeeAnchor.getAttribute('data-category');
|
|
loadDetailed('Park', category, zone, parkId, parkName);
|
|
return;
|
|
}
|
|
});
|
|
// set date constraints and load initial
|
|
setDateLimits();
|
|
// load initial
|
|
loadAbstractCounts();
|
|
loadZoneWisePercentage();
|
|
loadZoneSummary();
|
|
});
|
|
|
|
})();
|
|
</script>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
<!-- Park-wise Summary Modal -->
|
|
<div class="modal fade" id="parkWiseSummaryModal" tabindex="-1" aria-labelledby="parkWiseSummaryModalLabel"
|
|
aria-hidden="true">
|
|
<!-- <div class="modal-dialog modal-lg modal-dialog-scrollable"> -->
|
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="parkWiseSummaryModalLabel">Park-wise Summary</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="parkWiseSummaryModalBody">
|
|
Loading...
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-primary"
|
|
onclick="downloadExcelFromTable(document.getElementById('parkWiseSummaryModal'), 'park_wise_summary.xlsx')">
|
|
<i class="bi bi-download"></i> Download Excel
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail modal -->
|
|
<div class="modal fade" id="detailModal" tabindex="-1" aria-labelledby="detailModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
|
<!-- <div class="modal-dialog modal-fullscreen modal-dialog-scrollable"> -->
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="detailModalLabel">Details</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="detailModalBody">
|
|
Loading...
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-primary"
|
|
onclick="downloadExcelFromTable(document.getElementById('detailModal'), 'employee_details.xlsx')">
|
|
<i class="bi bi-download"></i> Download Excel
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Preview Modal -->
|
|
<div class="modal fade" id="imagePreviewModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Photo Preview</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body text-center">
|
|
<img id="previewImage" src="" class="img-fluid" style="max-height: 500px;">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
|
|
</html>
|