init backend presensi
This commit is contained in:
378
app/Views/dashboard/attendance_reports.php
Normal file
378
app/Views/dashboard/attendance_reports.php
Normal file
@@ -0,0 +1,378 @@
|
||||
<div class="space-y-6" id="attendance-reports-app">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">Laporan Absensi</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1 text-sm">
|
||||
Filter berdasarkan rentang tanggal, kelas, siswa, dan status. Tersedia rekap cepat per hari & kelas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100 text-sm mb-3">Filter</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<label class="block mb-1 text-gray-600 dark:text-gray-300">Dari tanggal</label>
|
||||
<input type="date" id="filter-from" class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-gray-600 dark:text-gray-300">Sampai tanggal</label>
|
||||
<input type="date" id="filter-to" class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-gray-600 dark:text-gray-300">Kelas</label>
|
||||
<select id="filter-class" class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
||||
<option value="">Semua kelas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-gray-600 dark:text-gray-300">Siswa</label>
|
||||
<select id="filter-student" class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
||||
<option value="">Semua siswa</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-gray-600 dark:text-gray-300">Status</label>
|
||||
<select id="filter-status" class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
||||
<option value="">Semua status</option>
|
||||
<option value="PRESENT">Hadir</option>
|
||||
<option value="LATE">Terlambat</option>
|
||||
<option value="OUTSIDE_ZONE">Di luar zona</option>
|
||||
<option value="NO_SCHEDULE">Tidak ada jadwal</option>
|
||||
<option value="INVALID_DEVICE">Device tidak valid</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button type="button" id="btn-apply-filter" class="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover">
|
||||
Terapkan Filter
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Catatan: Rekap berdasarkan data absensi yang terekam (anti double-scan sudah dijaga di level database).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading / Error -->
|
||||
<div id="reports-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-gray-500 dark:text-gray-400 hidden">
|
||||
Memuat data absensi…
|
||||
</div>
|
||||
<div id="reports-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-300 text-sm"></div>
|
||||
|
||||
<!-- Summary Section -->
|
||||
<div id="summary-section" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Rekap per Hari & Kelas</h2>
|
||||
<span id="summary-range" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
<div id="summary-empty" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Belum ada data untuk filter ini.
|
||||
</div>
|
||||
<div id="summary-table-wrap" class="hidden overflow-x-auto">
|
||||
<table class="w-full text-left text-xs sm:text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-medium">Tanggal</th>
|
||||
<th class="px-3 py-2 font-medium">Kelas</th>
|
||||
<th class="px-3 py-2 font-medium text-right">Total</th>
|
||||
<th class="px-3 py-2 font-medium text-right">Hadir</th>
|
||||
<th class="px-3 py-2 font-medium text-right">Terlambat</th>
|
||||
<th class="px-3 py-2 font-medium text-right">Di luar zona</th>
|
||||
<th class="px-3 py-2 font-medium text-right">No jadwal</th>
|
||||
<th class="px-3 py-2 font-medium text-right">Device tidak valid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="summary-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Section -->
|
||||
<div id="detail-section" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Detail Absensi</h2>
|
||||
</div>
|
||||
<div id="detail-empty" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Belum ada data untuk filter ini.
|
||||
</div>
|
||||
<div id="detail-table-wrap" class="hidden overflow-x-auto">
|
||||
<table class="w-full text-left text-xs sm:text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-medium">Tanggal</th>
|
||||
<th class="px-3 py-2 font-medium">Siswa</th>
|
||||
<th class="px-3 py-2 font-medium">Kelas</th>
|
||||
<th class="px-3 py-2 font-medium">Mapel</th>
|
||||
<th class="px-3 py-2 font-medium">Guru</th>
|
||||
<th class="px-3 py-2 font-medium">Status</th>
|
||||
<th class="px-3 py-2 font-medium">Waktu Masuk</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detail-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
|
||||
var apiReports = baseUrl + '/api/attendance/reports';
|
||||
var apiClasses = baseUrl + '/api/academic/classes';
|
||||
var apiStudents = baseUrl + '/api/academic/students';
|
||||
|
||||
var filterFrom = document.getElementById('filter-from');
|
||||
var filterTo = document.getElementById('filter-to');
|
||||
var filterClass = document.getElementById('filter-class');
|
||||
var filterStudent = document.getElementById('filter-student');
|
||||
var filterStatus = document.getElementById('filter-status');
|
||||
var btnApply = document.getElementById('btn-apply-filter');
|
||||
|
||||
var loadingEl = document.getElementById('reports-loading');
|
||||
var errorEl = document.getElementById('reports-error');
|
||||
var summarySection = document.getElementById('summary-section');
|
||||
var summaryRange = document.getElementById('summary-range');
|
||||
var summaryEmpty = document.getElementById('summary-empty');
|
||||
var summaryTableWrap = document.getElementById('summary-table-wrap');
|
||||
var summaryTbody = document.getElementById('summary-tbody');
|
||||
var detailSection = document.getElementById('detail-section');
|
||||
var detailEmpty = document.getElementById('detail-empty');
|
||||
var detailTableWrap = document.getElementById('detail-table-wrap');
|
||||
var detailTbody = document.getElementById('detail-tbody');
|
||||
|
||||
function fetchJson(url) {
|
||||
return fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}).then(function(res) {
|
||||
return res.json().then(function(data) {
|
||||
return { ok: res.ok, status: res.status, data: data };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
loadingEl.classList.remove('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
summarySection.classList.add('hidden');
|
||||
detailSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
loadingEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
hideLoading();
|
||||
errorEl.classList.remove('hidden');
|
||||
errorEl.textContent = msg || 'Terjadi kesalahan.';
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '-';
|
||||
var date = new Date(d);
|
||||
if (isNaN(date.getTime())) return d;
|
||||
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
||||
return pad(date.getDate()) + '/' + pad(date.getMonth() + 1) + '/' + date.getFullYear();
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
var date = new Date(dt);
|
||||
if (isNaN(date.getTime())) return dt;
|
||||
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
||||
return pad(date.getDate()) + '/' + pad(date.getMonth() + 1) + '/' + date.getFullYear() +
|
||||
' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
switch (status) {
|
||||
case 'PRESENT': return 'Hadir';
|
||||
case 'LATE': return 'Terlambat';
|
||||
case 'OUTSIDE_ZONE': return 'Di luar zona';
|
||||
case 'NO_SCHEDULE': return 'Tidak ada jadwal';
|
||||
case 'INVALID_DEVICE': return 'Device tidak valid';
|
||||
default: return status || '-';
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
switch (status) {
|
||||
case 'PRESENT': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'LATE': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'OUTSIDE_ZONE': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'NO_SCHEDULE': return 'bg-gray-100 text-gray-800 dark:bg-gray-700/50 dark:text-gray-100';
|
||||
case 'INVALID_DEVICE': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700/50 dark:text-gray-100';
|
||||
}
|
||||
}
|
||||
|
||||
function loadClasses(callback) {
|
||||
fetchJson(apiClasses + '?per_page=200').then(function(r) {
|
||||
if (!r.ok) return;
|
||||
var list = (r.data && r.data.data) ? r.data.data : [];
|
||||
var first = filterClass.options[0];
|
||||
filterClass.innerHTML = '';
|
||||
if (first) filterClass.appendChild(first);
|
||||
list.forEach(function(c) {
|
||||
var parts = [];
|
||||
if (c.grade) parts.push(c.grade);
|
||||
if (c.major) parts.push(c.major);
|
||||
if (c.name) parts.push(c.name);
|
||||
var label = c.full_label || parts.join(' ').trim() || ('Kelas ' + c.id);
|
||||
var opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = label;
|
||||
filterClass.appendChild(opt);
|
||||
});
|
||||
if (callback) callback(list);
|
||||
}).catch(function() {
|
||||
// silent; admin-only endpoint, bisa gagal untuk non-admin
|
||||
});
|
||||
}
|
||||
|
||||
function loadStudentsForClass(classId) {
|
||||
if (!classId) {
|
||||
var first = filterStudent.options[0];
|
||||
filterStudent.innerHTML = '';
|
||||
if (first) filterStudent.appendChild(first);
|
||||
return;
|
||||
}
|
||||
var url = apiStudents + '?class_id=' + encodeURIComponent(classId) + '&per_page=200';
|
||||
fetchJson(url).then(function(r) {
|
||||
if (!r.ok) return;
|
||||
var list = (r.data && r.data.data) ? r.data.data : [];
|
||||
var first = filterStudent.options[0];
|
||||
filterStudent.innerHTML = '';
|
||||
if (first) filterStudent.appendChild(first);
|
||||
list.forEach(function(s) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = s.name + (s.nisn ? ' (' + s.nisn + ')' : '');
|
||||
filterStudent.appendChild(opt);
|
||||
});
|
||||
}).catch(function() {
|
||||
// silent
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
showLoading();
|
||||
var params = [];
|
||||
var from = filterFrom.value;
|
||||
var to = filterTo.value;
|
||||
var cid = filterClass.value;
|
||||
var sid = filterStudent.value;
|
||||
var st = filterStatus.value;
|
||||
|
||||
if (from) params.push('from_date=' + encodeURIComponent(from));
|
||||
if (to) params.push('to_date=' + encodeURIComponent(to));
|
||||
if (cid) params.push('class_id=' + encodeURIComponent(cid));
|
||||
if (sid) params.push('student_id=' + encodeURIComponent(sid));
|
||||
if (st) params.push('status=' + encodeURIComponent(st));
|
||||
|
||||
var url = apiReports + (params.length ? ('?' + params.join('&')) : '');
|
||||
|
||||
fetchJson(url).then(function(r) {
|
||||
hideLoading();
|
||||
if (!r.ok) {
|
||||
showError(r.data && r.data.message ? r.data.message : 'Gagal memuat laporan');
|
||||
return;
|
||||
}
|
||||
var payload = r.data && r.data.data ? r.data.data : r.data;
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
showError('Response tidak valid');
|
||||
return;
|
||||
}
|
||||
|
||||
var filters = payload.filters || {};
|
||||
var summary = Array.isArray(payload.summary) ? payload.summary : [];
|
||||
var records = Array.isArray(payload.records) ? payload.records : [];
|
||||
|
||||
// Update range label
|
||||
if (filters.from_date && filters.to_date) {
|
||||
summaryRange.textContent = formatDate(filters.from_date) + ' s.d. ' + formatDate(filters.to_date);
|
||||
} else if (filters.from_date) {
|
||||
summaryRange.textContent = 'Sejak ' + formatDate(filters.from_date);
|
||||
} else if (filters.to_date) {
|
||||
summaryRange.textContent = 'Sampai ' + formatDate(filters.to_date);
|
||||
} else {
|
||||
summaryRange.textContent = '';
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (summary.length === 0) {
|
||||
summaryEmpty.classList.remove('hidden');
|
||||
summaryTableWrap.classList.add('hidden');
|
||||
summaryTbody.innerHTML = '';
|
||||
} else {
|
||||
summaryEmpty.classList.add('hidden');
|
||||
summaryTableWrap.classList.remove('hidden');
|
||||
summaryTbody.innerHTML = '';
|
||||
summary.forEach(function(row) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td class="px-3 py-2">' + formatDate(row.attendance_date) + '</td>' +
|
||||
'<td class="px-3 py-2">' + (row.class_label || '-') + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + (row.total || 0) + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + (row.present || 0) + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + (row.late || 0) + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + (row.outside_zone || 0) + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + (row.no_schedule || 0) + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + (row.invalid_device || 0) + '</td>';
|
||||
summaryTbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Detail
|
||||
if (records.length === 0) {
|
||||
detailEmpty.classList.remove('hidden');
|
||||
detailTableWrap.classList.add('hidden');
|
||||
detailTbody.innerHTML = '';
|
||||
} else {
|
||||
detailEmpty.classList.add('hidden');
|
||||
detailTableWrap.classList.remove('hidden');
|
||||
detailTbody.innerHTML = '';
|
||||
records.forEach(function(r) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td class="px-3 py-2">' + formatDate(r.attendance_date) + '</td>' +
|
||||
'<td class="px-3 py-2">' + (r.student_name || '-') + (r.nisn ? ' (' + r.nisn + ')' : '') + '</td>' +
|
||||
'<td class="px-3 py-2">' + (r.class_label || '-') + '</td>' +
|
||||
'<td class="px-3 py-2">' + (r.subject_name || '-') + '</td>' +
|
||||
'<td class="px-3 py-2">' + (r.teacher_name || '-') + '</td>' +
|
||||
'<td class="px-3 py-2"><span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium ' + statusClass(r.status) + '">' + statusLabel(r.status) + '</span></td>' +
|
||||
'<td class="px-3 py-2">' + (r.checkin_at ? formatDateTime(r.checkin_at) : '-') + '</td>';
|
||||
detailTbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
summarySection.classList.remove('hidden');
|
||||
detailSection.classList.remove('hidden');
|
||||
}).catch(function() {
|
||||
hideLoading();
|
||||
showError('Gagal memuat laporan (jaringan).');
|
||||
});
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
btnApply.addEventListener('click', applyFilters);
|
||||
filterClass.addEventListener('change', function() {
|
||||
loadStudentsForClass(this.value || '');
|
||||
});
|
||||
|
||||
// Init default date range (7 hari terakhir)
|
||||
var today = new Date();
|
||||
var sevenAgo = new Date(today);
|
||||
sevenAgo.setDate(sevenAgo.getDate() - 7);
|
||||
filterTo.value = today.toISOString().split('T')[0];
|
||||
filterFrom.value = sevenAgo.toISOString().split('T')[0];
|
||||
|
||||
// Load classes (untuk admin)
|
||||
loadClasses();
|
||||
|
||||
// Load first data
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user