init backend presensi

This commit is contained in:
mwpn
2026-03-05 14:37:36 +07:00
commit b4fda6b9c9
319 changed files with 27261 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold">Pengaturan Akademik</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Kelola data master akademik: kelas, mata pelajaran, guru, jam pelajaran, dan jadwal. <strong>Jam masuk &amp; pulang</strong> atur di <strong>Jam Pelajaran</strong> + <strong>Schedule Builder</strong>.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<a href="<?= base_url('dashboard/academic/classes') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-group text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Kelas</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Kelola kelas dan wali kelas</p>
</div>
</div>
</a>
<a href="<?= base_url('dashboard/academic/students') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-user text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Siswa</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Kelola data siswa</p>
</div>
</div>
</a>
<a href="<?= base_url('dashboard/academic/subjects') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-book text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Mata Pelajaran</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Kelola mata pelajaran</p>
</div>
</div>
</a>
<a href="<?= base_url('dashboard/academic/teachers') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-user-circle text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Guru / PTK</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Kelola data guru dan wali kelas</p>
</div>
</div>
</a>
<a href="<?= base_url('dashboard/academic/lesson-slots') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-time-five text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Jam Pelajaran</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Kelola slot jam pelajaran</p>
</div>
</div>
</a>
<a href="<?= base_url('dashboard/academic/dapodik') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-cloud-download text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Dapodik</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Sinkron siswa & mapping rombel</p>
</div>
</div>
</a>
<a href="<?= base_url('dashboard/academic/schedule-builder') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-grid-alt text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Schedule Builder</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Buat jadwal per kelas</p>
</div>
</div>
</a>
<a href="<?= base_url('dashboard/academic/discipline-settings') ?>" class="block rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm hover:border-primary hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
<i class="bx bx-error-alt text-2xl"></i>
</div>
<div>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Aturan Poin Pelanggaran</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Kelola jenis pelanggaran dan skor poin</p>
</div>
</div>
</a>
</div>
</div>

View File

@@ -0,0 +1,213 @@
<div class="space-y-6" id="attendance-report-app">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-xl font-semibold">Schedule Attendance Report</h1>
<div class="flex items-center gap-2">
<label for="report-date" class="text-sm text-gray-600 dark:text-gray-400">Date</label>
<input type="date" id="report-date" class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm focus:ring-2 focus:ring-primary"
value="<?= date('Y-m-d') ?>">
</div>
</div>
<div id="report-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Loading report…
</div>
<div id="report-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="report-content" class="hidden space-y-6">
<!-- SECTION 1: Schedule Info -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
<h2 class="text-lg font-semibold">Schedule Info</h2>
</div>
<div class="p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Subject</p>
<p id="info-subject" class="mt-1 font-medium"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Class</p>
<p id="info-class" class="mt-1 font-medium"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Teacher</p>
<p id="info-teacher" class="mt-1 font-medium"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Time</p>
<p id="info-time" class="mt-1 font-medium"></p>
</div>
</div>
</div>
<!-- SECTION 2: Summary Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Expected</p>
<p id="summary-expected" class="mt-2 text-2xl font-bold text-gray-900 dark:text-white">0</p>
</div>
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Present</p>
<p id="summary-present" class="mt-2 text-2xl font-bold text-green-600 dark:text-green-400">0</p>
</div>
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Late</p>
<p id="summary-late" class="mt-2 text-2xl font-bold text-yellow-600 dark:text-yellow-400">0</p>
</div>
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Absent</p>
<p id="summary-absent" class="mt-2 text-2xl font-bold text-red-600 dark:text-red-400">0</p>
</div>
</div>
<!-- SECTION 3: Present List -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
<h2 class="text-lg font-semibold">Present</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">NIS</th>
<th class="px-6 py-3 font-medium">Name</th>
<th class="px-6 py-3 font-medium">Status</th>
<th class="px-6 py-3 font-medium">Check-in time</th>
</tr>
</thead>
<tbody id="present-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
<!-- SECTION 4: Absent List -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
<h2 class="text-lg font-semibold">Absent</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">NIS</th>
<th class="px-6 py-3 font-medium">Name</th>
</tr>
</thead>
<tbody id="absent-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(function() {
var scheduleId = <?= (int) $scheduleId ?>;
var baseUrl = '<?= base_url() ?>';
var apiUrl = baseUrl.replace(/\/$/, '') + '/api/attendance/report/schedule/' + scheduleId;
var reportLoading = document.getElementById('report-loading');
var reportError = document.getElementById('report-error');
var reportContent = document.getElementById('report-content');
var reportDate = document.getElementById('report-date');
function showLoading() {
reportLoading.classList.remove('hidden');
reportError.classList.add('hidden');
reportContent.classList.add('hidden');
}
function showError(msg) {
reportLoading.classList.add('hidden');
reportContent.classList.add('hidden');
reportError.classList.remove('hidden');
reportError.textContent = msg;
}
function showContent() {
reportLoading.classList.add('hidden');
reportError.classList.add('hidden');
reportContent.classList.remove('hidden');
}
function escapeHtml(str) {
if (str == null) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatTime(iso) {
if (!iso) return '';
var d = new Date(iso);
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function statusBadgeClass(status) {
return status === 'LATE' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
}
function renderReport(data) {
var schedule = data.schedule || {};
var summary = data.summary || {};
var present = data.present || [];
var absent = data.absent || [];
document.getElementById('info-subject').textContent = schedule.subject || '';
document.getElementById('info-class').textContent = schedule.class_name || ('Class #' + (schedule.class_id || ''));
document.getElementById('info-teacher').textContent = schedule.teacher || '';
document.getElementById('info-time').textContent = (schedule.start_time && schedule.end_time) ? (schedule.start_time + ' ' + schedule.end_time) : '';
document.getElementById('summary-expected').textContent = summary.expected_total != null ? summary.expected_total : 0;
document.getElementById('summary-present').textContent = summary.present_total != null ? summary.present_total : 0;
document.getElementById('summary-late').textContent = summary.late_total != null ? summary.late_total : 0;
document.getElementById('summary-absent').textContent = summary.absent_total != null ? summary.absent_total : 0;
var presentTbody = document.getElementById('present-tbody');
presentTbody.innerHTML = '';
present.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
var statusClass = statusBadgeClass(row.status);
tr.innerHTML = '<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.nisn) + '</td>' +
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.name) + '</td>' +
'<td class="px-6 py-3"><span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium ' + statusClass + '">' + escapeHtml(row.status) + '</span></td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + formatTime(row.checkin_at) + '</td>';
presentTbody.appendChild(tr);
});
var absentTbody = document.getElementById('absent-tbody');
absentTbody.innerHTML = '';
absent.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
tr.innerHTML = '<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.nisn) + '</td>' +
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.name) + '</td>';
absentTbody.appendChild(tr);
});
showContent();
}
function loadReport() {
showLoading();
var date = reportDate.value || new Date().toISOString().slice(0, 10);
var url = apiUrl + '?date=' + encodeURIComponent(date);
fetch(url, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
showError(r.data && r.data.message ? r.data.message : 'Failed to load report');
return;
}
var data = r.data && r.data.data ? r.data.data : r.data;
if (!data) {
showError('Invalid response');
return;
}
renderReport(data);
})
.catch(function() {
showError('Network error');
});
}
reportDate.addEventListener('change', loadReport);
loadReport();
})();
</script>

View 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 &amp; 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 &amp; 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>

View File

@@ -0,0 +1,352 @@
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold">Kelas</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Kelola data kelas dan wali kelas.</p>
</div>
<button type="button" id="btn-add-class" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors">
<i class="bx bx-plus text-xl"></i>
<span>Tambah Kelas</span>
</button>
</div>
<div id="classes-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Memuat…
</div>
<div id="classes-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="classes-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">Label</th>
<th class="px-6 py-3 font-medium">Wali Kelas</th>
<th class="px-6 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody id="classes-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="modal-class" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-class-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div class="p-6">
<h2 id="modal-class-title" class="text-lg font-semibold mb-4">Tambah Kelas</h2>
<form id="form-class">
<input type="hidden" id="form-class-id" value="">
<div class="space-y-4">
<div>
<label for="form-class-grade" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tingkat <span class="text-red-500">*</span></label>
<input type="text" id="form-class-grade" name="grade" required maxlength="50" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Contoh: X, 10, 11, 12">
</div>
<div>
<label for="form-class-major" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Jurusan <span class="text-red-500">*</span></label>
<input type="text" id="form-class-major" name="major" required maxlength="50" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Contoh: IPA, IPS, Bahasa">
</div>
<div>
<label for="form-class-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rombel <span class="text-red-500">*</span></label>
<input type="text" id="form-class-name" name="name" required maxlength="100" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Contoh: 1, 2, 3 (muncul jadi X IPA 1, X IPA 2, ...)">
</div>
<div>
<label for="form-class-wali" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Wali Kelas</label>
<select id="form-class-wali" name="wali_user_id" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
<option value=""> Tidak ada </option>
</select>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" id="form-class-submit" class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover">Simpan</button>
<button type="button" id="form-class-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div id="modal-delete" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-delete-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-sm rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold mb-2">Hapus Kelas</h2>
<p id="modal-delete-message" class="text-gray-600 dark:text-gray-400 mb-6">Yakin ingin menghapus kelas ini?</p>
<div class="flex gap-3">
<button type="button" id="modal-delete-confirm" class="flex-1 px-4 py-2.5 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700">Hapus</button>
<button type="button" id="modal-delete-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</div>
</div>
</div>
<!-- Toast container -->
<div id="toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiClasses = baseUrl + '/api/academic/classes';
var apiUsers = baseUrl + '/api/users';
var loading = document.getElementById('classes-loading');
var errorEl = document.getElementById('classes-error');
var content = document.getElementById('classes-content');
var tbody = document.getElementById('classes-tbody');
var btnAdd = document.getElementById('btn-add-class');
var modalClass = document.getElementById('modal-class');
var modalClassTitle = document.getElementById('modal-class-title');
var formClass = document.getElementById('form-class');
var formClassId = document.getElementById('form-class-id');
var formClassName = document.getElementById('form-class-name');
var formClassGrade = document.getElementById('form-class-grade');
var formClassMajor = document.getElementById('form-class-major');
var formClassWali = document.getElementById('form-class-wali');
var formClassSubmit = document.getElementById('form-class-submit');
var formClassCancel = document.getElementById('form-class-cancel');
var modalClassBackdrop = document.getElementById('modal-class-backdrop');
var modalDelete = document.getElementById('modal-delete');
var modalDeleteMessage = document.getElementById('modal-delete-message');
var modalDeleteConfirm = document.getElementById('modal-delete-confirm');
var modalDeleteCancel = document.getElementById('modal-delete-cancel');
var modalDeleteBackdrop = document.getElementById('modal-delete-backdrop');
var toastContainer = document.getElementById('toast-container');
var waliUsers = [];
var deleteTargetId = null;
function showLoading() {
loading.classList.remove('hidden');
errorEl.classList.add('hidden');
content.classList.add('hidden');
}
function showError(msg) {
loading.classList.add('hidden');
content.classList.add('hidden');
errorEl.classList.remove('hidden');
errorEl.textContent = msg;
}
function showContent() {
loading.classList.add('hidden');
errorEl.classList.add('hidden');
content.classList.remove('hidden');
}
function escapeHtml(str) {
if (str == null) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' +
(type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() {
if (el.parentNode) el.parentNode.removeChild(el);
}, 3000);
}
function fetchOptions() {
return { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } };
}
function postOptions(body) {
return { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) };
}
function putOptions(body) {
return { method: 'PUT', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) };
}
function deleteOptions() {
return { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } };
}
function loadWaliUsers(callback) {
if (waliUsers.length) {
if (callback) callback();
return;
}
Promise.all([
fetch(apiUsers + '?role=WALI_KELAS', fetchOptions()).then(function(r) { return r.json(); }),
fetch(apiUsers + '?role=GURU_MAPEL', fetchOptions()).then(function(r) { return r.json(); })
]).then(function(results) {
var byId = {};
results.forEach(function(res) {
var list = (res && res.data) ? res.data : (res && Array.isArray(res) ? res : []);
list.forEach(function(u) {
byId[u.id] = { id: u.id, name: u.name || u.email };
});
});
waliUsers = Object.values(byId).sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
if (callback) callback();
}).catch(function() {
waliUsers = [];
if (callback) callback();
});
}
function fillWaliDropdown(selectedId) {
var id = selectedId ? (typeof selectedId === 'number' ? selectedId : parseInt(selectedId, 10)) : null;
formClassWali.innerHTML = '<option value="">— Tidak ada —</option>';
waliUsers.forEach(function(u) {
var opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.name;
if (id && u.id === id) opt.selected = true;
formClassWali.appendChild(opt);
});
}
function loadClasses() {
showLoading();
fetch(apiClasses, fetchOptions())
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
showError(r.data && r.data.message ? r.data.message : 'Gagal memuat kelas');
return;
}
var list = (r.data && r.data.data) ? r.data.data : (Array.isArray(r.data) ? r.data : []);
tbody.innerHTML = '';
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
var label = row.full_label ? escapeHtml(row.full_label) : (escapeHtml(row.grade) + ' ' + escapeHtml(row.major) + ' ' + escapeHtml(row.name)).trim() || '';
var waliName = (row.wali_name || row.wali_user_name) ? escapeHtml(row.wali_name || row.wali_user_name) : '';
tr.innerHTML =
'<td class="px-6 py-3 font-medium">' + label + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + waliName + '</td>' +
'<td class="px-6 py-3 text-right">' +
'<button type="button" class="btn-edit px-3 py-1.5 rounded-lg text-primary hover:bg-primary/10" data-id="' + row.id + '" data-name="' + escapeHtml(row.name) + '" data-grade="' + escapeHtml(row.grade) + '" data-major="' + escapeHtml(row.major || '') + '" data-wali-id="' + (row.wali_user_id || '') + '">Edit</button> ' +
'<button type="button" class="btn-delete px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20" data-id="' + row.id + '" data-name="' + escapeHtml(row.full_label || (row.grade + ' ' + (row.major || '') + ' ' + row.name).trim()) + '">Hapus</button>' +
'</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() { openEdit(parseInt(btn.getAttribute('data-id'), 10)); });
});
tbody.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() { openDelete(parseInt(btn.getAttribute('data-id'), 10), btn.getAttribute('data-name')); });
});
showContent();
})
.catch(function() { showError('Gagal memuat data'); });
}
function openAdd() {
formClassId.value = '';
formClassName.value = '';
formClassGrade.value = '';
formClassMajor.value = '';
modalClassTitle.textContent = 'Tambah Kelas';
fillWaliDropdown(null);
modalClass.classList.remove('hidden');
}
function openEdit(id) {
var btn = tbody.querySelector('.btn-edit[data-id="' + id + '"]');
if (!btn) return;
formClassId.value = id;
formClassName.value = btn.getAttribute('data-name') || '';
formClassGrade.value = btn.getAttribute('data-grade') || '';
formClassMajor.value = btn.getAttribute('data-major') || '';
var waliId = btn.getAttribute('data-wali-id');
waliId = waliId ? parseInt(waliId, 10) : null;
modalClassTitle.textContent = 'Edit Kelas';
fillWaliDropdown(waliId);
modalClass.classList.remove('hidden');
}
function openDelete(id, name) {
deleteTargetId = id;
modalDeleteMessage.textContent = 'Yakin ingin menghapus kelas "' + (name || '') + '"?';
modalDelete.classList.remove('hidden');
}
function closeModalClass() {
modalClass.classList.add('hidden');
}
function closeModalDelete() {
modalDelete.classList.add('hidden');
deleteTargetId = null;
}
formClass.addEventListener('submit', function(e) {
e.preventDefault();
var id = formClassId.value ? parseInt(formClassId.value, 10) : null;
var payload = {
name: formClassName.value.trim(),
grade: formClassGrade.value.trim(),
major: formClassMajor.value.trim(),
wali_user_id: formClassWali.value ? parseInt(formClassWali.value, 10) : null
};
formClassSubmit.disabled = true;
var url = apiClasses;
var opts = postOptions(payload);
if (id) {
url = apiClasses + '/' + id;
opts = putOptions(payload);
}
fetch(url, opts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
formClassSubmit.disabled = false;
if (!r.ok) {
showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan', 'error');
return;
}
showToast(id ? 'Kelas berhasil diubah' : 'Kelas berhasil ditambahkan');
closeModalClass();
loadClasses();
})
.catch(function() {
formClassSubmit.disabled = false;
showToast('Gagal menyimpan', 'error');
});
});
formClassCancel.addEventListener('click', closeModalClass);
modalClassBackdrop.addEventListener('click', closeModalClass);
modalDeleteConfirm.addEventListener('click', function() {
if (!deleteTargetId) return;
var id = deleteTargetId;
modalDeleteConfirm.disabled = true;
fetch(apiClasses + '/' + id, deleteOptions())
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
modalDeleteConfirm.disabled = false;
if (!r.ok) {
showToast(r.data && r.data.message ? r.data.message : 'Gagal menghapus', 'error');
return;
}
showToast('Kelas berhasil dihapus');
closeModalDelete();
loadClasses();
})
.catch(function() {
modalDeleteConfirm.disabled = false;
showToast('Gagal menghapus', 'error');
});
});
modalDeleteCancel.addEventListener('click', closeModalDelete);
modalDeleteBackdrop.addEventListener('click', closeModalDelete);
btnAdd.addEventListener('click', function() {
loadWaliUsers(function() {
openAdd();
});
});
loadWaliUsers(function() {
loadClasses();
});
})();
</script>

View File

@@ -0,0 +1,255 @@
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold">Dapodik</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Sinkron peserta didik dari Dapodik WebService dan mapping rombel ke kelas internal.</p>
</div>
<!-- A) Sync Students -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Sinkron Siswa</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Ambil data peserta didik dari Dapodik dan upsert ke data siswa. Rombel yang belum di-map akan tetap unmapped.</p>
<button type="button" id="btn-sync" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<i class="bx bx-cloud-download text-xl"></i>
<span id="btn-sync-text">Jalankan Sinkronisasi</span>
</button>
<div id="sync-progress" class="mt-4 hidden">
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
<span id="sync-progress-text">0 / 0 (0%)</span>
</div>
<div class="h-2.5 w-full rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div id="sync-progress-bar" class="h-full bg-primary transition-all duration-300 ease-out" style="width: 0%"></div>
</div>
</div>
<div id="sync-result" class="mt-4 hidden">
<pre id="sync-result-body" class="p-4 rounded-lg bg-gray-100 dark:bg-gray-900 text-sm overflow-auto max-h-64"></pre>
</div>
</div>
<!-- B) Rombel Mapping -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Mapping Rombel</h2>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="filter-unmapped" class="rounded border-gray-300 text-primary focus:ring-primary">
<span class="text-sm text-gray-700 dark:text-gray-300">Hanya unmapped</span>
</label>
</div>
<div id="mappings-loading" class="py-8 text-center text-gray-500 dark:text-gray-400">Memuat…</div>
<div id="mappings-error" class="hidden py-4 text-red-600 dark:text-red-400"></div>
<div id="mappings-content" class="hidden overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-4 py-3 font-medium">Dapodik Rombel</th>
<th class="px-4 py-3 font-medium">Kelas (internal)</th>
<th class="px-4 py-3 font-medium">Last Seen</th>
<th class="px-4 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody id="mappings-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
<div id="toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiSync = baseUrl + '/api/academic/dapodik/sync/students';
var apiStatus = baseUrl + '/api/academic/dapodik/sync/status/';
var apiRombels = baseUrl + '/api/academic/dapodik/rombels';
var apiClasses = baseUrl + '/api/academic/classes';
var btnSync = document.getElementById('btn-sync');
var btnSyncText = document.getElementById('btn-sync-text');
var syncProgress = document.getElementById('sync-progress');
var syncProgressText = document.getElementById('sync-progress-text');
var syncProgressBar = document.getElementById('sync-progress-bar');
var syncResult = document.getElementById('sync-result');
var syncResultBody = document.getElementById('sync-result-body');
var pollTimer = null;
var filterUnmapped = document.getElementById('filter-unmapped');
var mappingsLoading = document.getElementById('mappings-loading');
var mappingsError = document.getElementById('mappings-error');
var mappingsContent = document.getElementById('mappings-content');
var mappingsTbody = document.getElementById('mappings-tbody');
var toastContainer = document.getElementById('toast-container');
var classesList = [];
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 4000);
}
function escapeHtml(str) { if (str == null) return ''; var d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function loadClasses(callback) {
fetch(apiClasses, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(res) {
var list = (res && res.data && res.data.data) ? res.data.data : (Array.isArray(res && res.data) ? res.data : []);
classesList = list;
if (callback) callback();
})
.catch(function() { if (callback) callback(); });
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
btnSync.disabled = false;
btnSyncText.textContent = 'Jalankan Sinkronisasi';
}
function pollStatus(jobId) {
fetch(apiStatus + jobId, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); })
.then(function(r) {
var d = r.data && r.data.data ? r.data.data : r.data;
if (!d) return;
var total = d.total_rows || 0;
var processed = d.processed_rows || 0;
var percent = d.percent || (total > 0 ? Math.round((processed / total) * 100) : 0);
var status = d.status || 'running';
syncProgressText.textContent = processed + ' / ' + (total > 0 ? total : '?') + ' (' + percent + '%)';
syncProgressBar.style.width = Math.min(percent, 100) + '%';
if (status === 'completed') {
stopPolling();
syncProgress.classList.add('hidden');
showToast('Sinkronisasi selesai');
loadMappings();
} else if (status === 'failed') {
stopPolling();
syncProgress.classList.add('hidden');
showToast(d.message || 'Sinkronisasi gagal', 'error');
} else {
pollTimer = setTimeout(function() { pollStatus(jobId); }, 1000);
}
})
.catch(function() {
pollTimer = setTimeout(function() { pollStatus(jobId); }, 1000);
});
}
btnSync.addEventListener('click', function() {
if (btnSync.disabled) return;
btnSync.disabled = true;
btnSyncText.textContent = 'Memulai…';
syncResult.classList.add('hidden');
syncProgress.classList.add('hidden');
fetch(apiSync, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ limit: 100, max_pages: 50 })
})
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
stopPolling();
showToast(r.data && r.data.message ? r.data.message : 'Gagal memulai sinkronisasi', 'error');
return;
}
var d = r.data && r.data.data ? r.data.data : r.data;
var jobId = d && d.job_id ? d.job_id : null;
var total = d && d.total_rows != null ? d.total_rows : 0;
if (!jobId) {
stopPolling();
showToast('Invalid response', 'error');
return;
}
btnSyncText.textContent = 'Sinkronisasi berjalan…';
syncProgress.classList.remove('hidden');
syncProgressText.textContent = '0 / ' + (total > 0 ? total : '?') + ' (0%)';
syncProgressBar.style.width = '0%';
pollStatus(jobId);
})
.catch(function() {
stopPolling();
showToast('Gagal memulai sinkronisasi', 'error');
});
});
function buildRombelsUrl() {
var url = apiRombels;
if (filterUnmapped.checked) url += '?unmapped_only=1';
return url;
}
function loadMappings() {
mappingsLoading.classList.remove('hidden');
mappingsError.classList.add('hidden');
mappingsContent.classList.add('hidden');
fetch(buildRombelsUrl(), { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); })
.then(function(r) {
mappingsLoading.classList.add('hidden');
if (!r.ok) {
mappingsError.textContent = r.data && r.data.message ? r.data.message : 'Gagal memuat';
mappingsError.classList.remove('hidden');
return;
}
var list = (r.data && r.data.data) ? r.data.data : (Array.isArray(r.data) ? r.data : []);
mappingsTbody.innerHTML = '';
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
tr.setAttribute('data-id', row.id);
var selectHtml = '<option value="">— Tidak di-map —</option>';
classesList.forEach(function(c) {
var label = c.full_label || (c.grade + ' ' + (c.major || '') + ' ' + c.name).trim();
var sel = row.class_id === c.id ? ' selected' : '';
selectHtml += '<option value="' + c.id + '"' + sel + '>' + escapeHtml(label) + '</option>';
});
var lastSeen = row.last_seen_at ? escapeHtml(row.last_seen_at) : '';
tr.innerHTML =
'<td class="px-4 py-3 font-medium">' + escapeHtml(row.dapodik_rombel) + '</td>' +
'<td class="px-4 py-3"><select class="mapping-class-select w-full max-w-xs px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" data-id="' + row.id + '">' + selectHtml + '</select></td>' +
'<td class="px-4 py-3 text-gray-600 dark:text-gray-400 text-sm">' + lastSeen + '</td>' +
'<td class="px-4 py-3 text-right"><button type="button" class="btn-save-mapping px-3 py-1.5 rounded-lg bg-primary text-white text-sm hover:bg-primary-hover" data-id="' + row.id + '">Simpan</button></td>';
mappingsTbody.appendChild(tr);
});
mappingsTbody.querySelectorAll('.btn-save-mapping').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = parseInt(btn.getAttribute('data-id'), 10);
var row = mappingsTbody.querySelector('tr[data-id="' + id + '"]');
var sel = row ? row.querySelector('.mapping-class-select') : null;
var classId = sel && sel.value ? parseInt(sel.value, 10) : null;
btn.disabled = true;
fetch(apiRombels + '/' + id, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ class_id: classId })
})
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(res) {
btn.disabled = false;
if (res.ok) showToast('Mapping disimpan');
else showToast(res.data && res.data.message ? res.data.message : 'Gagal', 'error');
})
.catch(function() { btn.disabled = false; showToast('Gagal menyimpan', 'error'); });
});
});
mappingsContent.classList.remove('hidden');
})
.catch(function() {
mappingsLoading.classList.add('hidden');
mappingsError.textContent = 'Gagal memuat data';
mappingsError.classList.remove('hidden');
});
}
filterUnmapped.addEventListener('change', loadMappings);
loadClasses(function() { loadMappings(); });
})();
</script>

View File

@@ -0,0 +1,114 @@
<div class="space-y-6">
<h1 class="text-xl font-semibold">Device Absen</h1>
<p class="text-gray-600 dark:text-gray-400 text-sm">Daftar perangkat absensi dan status online. Koordinat presensi diatur terpusat di <a href="<?= base_url('dashboard/presence-settings') ?>" class="text-primary hover:underline">Pengaturan Presensi</a>.</p>
<div id="devices-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Loading…
</div>
<div id="devices-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="devices-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">Device Code</th>
<th class="px-6 py-3 font-medium">Device Name</th>
<th class="px-6 py-3 font-medium">Active</th>
<th class="px-6 py-3 font-medium">Last Seen</th>
<th class="px-6 py-3 font-medium">Status</th>
<th class="px-6 py-3 font-medium">Zona</th>
</tr>
</thead>
<tbody id="devices-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiUrl = baseUrl + '/api/dashboard/devices';
var loading = document.getElementById('devices-loading');
var errorEl = document.getElementById('devices-error');
var content = document.getElementById('devices-content');
var tbody = document.getElementById('devices-tbody');
var currentDevices = [];
function showLoading() {
loading.classList.remove('hidden');
errorEl.classList.add('hidden');
content.classList.add('hidden');
}
function showError(msg) {
loading.classList.add('hidden');
content.classList.add('hidden');
errorEl.classList.remove('hidden');
errorEl.textContent = msg;
}
function showContent() {
loading.classList.add('hidden');
errorEl.classList.add('hidden');
content.classList.remove('hidden');
}
function escapeHtml(str) {
if (str == null) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function loadDevices() {
showLoading();
fetch(apiUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
showError(r.data && r.data.message ? r.data.message : 'Failed to load devices');
return;
}
var list = r.data && r.data.data ? r.data.data : r.data;
if (!Array.isArray(list)) {
showError('Invalid response');
return;
}
currentDevices = list;
tbody.innerHTML = '';
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
var activeText = row.is_active ? 'Yes' : 'No';
var activeClass = row.is_active ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400';
var lastSeen = row.last_seen_at ? escapeHtml(row.last_seen_at) : '';
var status = (row.online_status || 'offline').toLowerCase();
var statusClass = status === 'online'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
var statusLabel = status === 'online' ? 'Online' : 'Offline';
var zoneText = 'Terpusat (zona sekolah)';
tr.innerHTML =
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.device_code) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.device_name) + '</td>' +
'<td class="px-6 py-3 ' + activeClass + '">' + escapeHtml(activeText) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + lastSeen + '</td>' +
'<td class="px-6 py-3"><span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium ' + statusClass + '">' + escapeHtml(statusLabel) + '</span></td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400 text-xs">' + escapeHtml(zoneText) + '</td>';
tbody.appendChild(tr);
});
showContent();
})
.catch(function() {
showError('Network error');
});
}
// Init
loadDevices();
})();
</script>

View File

@@ -0,0 +1,507 @@
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold">Poin Pelanggaran Siswa</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1 text-sm">
Guru/Wali Kelas dapat mencatat pelanggaran dan melihat rekap poin perilaku siswa.
</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Filter & Form -->
<div class="space-y-6 lg:col-span-1">
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm space-y-3">
<h2 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Filter</h2>
<div class="space-y-3 text-sm">
<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 class="grid grid-cols-2 gap-2">
<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>
<button type="button" id="btn-filter" class="w-full mt-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover">
Terapkan Filter
</button>
</div>
</div>
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm space-y-3">
<h2 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Catat Pelanggaran Baru</h2>
<div class="space-y-3 text-sm">
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Kelas</label>
<select id="form-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="">Pilih kelas</option>
</select>
</div>
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Siswa</label>
<select id="form-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="">Pilih siswa</option>
</select>
</div>
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Kategori Pelanggaran</label>
<select id="form-violation-category" 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="">Pilih kategori</option>
</select>
</div>
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Jenis Pelanggaran</label>
<select id="form-violation" 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="">Pilih jenis pelanggaran</option>
</select>
</div>
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Tanggal & Waktu</label>
<input type="datetime-local" id="form-occurred-at" 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">Catatan (opsional)</label>
<textarea id="form-notes" rows="2" 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"></textarea>
</div>
<button type="button" id="btn-save-violation" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700">
Simpan Pelanggaran
</button>
</div>
</div>
</div>
<!-- Rekap / List -->
<div class="lg:col-span-2 space-y-4">
<div class="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 Pelanggaran</h2>
<span id="recap-range" class="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<div id="recap-content" class="text-sm text-gray-600 dark:text-gray-300">
<p id="recap-empty" class="text-gray-500 dark:text-gray-400">Belum ada data untuk filter ini.</p>
<div id="recap-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">Siswa</th>
<th class="px-3 py-2 font-medium">Kelas</th>
<th class="px-3 py-2 font-medium text-right">Total Poin</th>
<th class="px-3 py-2 font-medium text-right">Jumlah Kasus</th>
<th class="px-3 py-2 font-medium">Level / Tindakan</th>
</tr>
</thead>
<tbody id="recap-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
<div class="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">Riwayat Pelanggaran</h2>
</div>
<div id="violations-loading" class="py-10 text-center text-gray-500 dark:text-gray-400 hidden">Memuat…</div>
<div id="violations-error" class="hidden py-3 text-sm text-red-600 dark:text-red-400"></div>
<div id="violations-content" 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">Pelanggaran</th>
<th class="px-3 py-2 font-medium text-right">Skor</th>
<th class="px-3 py-2 font-medium">Guru/Wali</th>
</tr>
</thead>
<tbody id="violations-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div id="discipline-toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiClasses = baseUrl + '/api/academic/classes';
var apiStudents = baseUrl + '/api/academic/students';
var apiViolationsMaster = baseUrl + '/api/discipline/violations';
var apiDisciplineLevels = baseUrl + '/api/discipline/levels';
var apiStudentViolations = baseUrl + '/api/discipline/student-violations';
var filterClass = document.getElementById('filter-class');
var filterFrom = document.getElementById('filter-from');
var filterTo = document.getElementById('filter-to');
var btnFilter = document.getElementById('btn-filter');
var formClass = document.getElementById('form-class');
var formStudent = document.getElementById('form-student');
var formViolationCategory = document.getElementById('form-violation-category');
var formViolation = document.getElementById('form-violation');
var formOccurredAt = document.getElementById('form-occurred-at');
var formNotes = document.getElementById('form-notes');
var btnSave = document.getElementById('btn-save-violation');
var recapRange = document.getElementById('recap-range');
var recapEmpty = document.getElementById('recap-empty');
var recapTableWrap = document.getElementById('recap-table-wrap');
var recapTbody = document.getElementById('recap-tbody');
var vLoading = document.getElementById('violations-loading');
var vError = document.getElementById('violations-error');
var vContent = document.getElementById('violations-content');
var vTbody = document.getElementById('violations-tbody');
var toastContainer = document.getElementById('discipline-toast-container');
var classesList = [];
var violationsMaster = [];
var violationItemsByCategory = {};
var disciplineLevels = [];
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 4000);
}
function fetchJson(url) {
return fetch(url, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); });
}
function escapeHtml(str) {
if (str == null) return '';
var d = document.createElement('div'); d.textContent = str; return d.innerHTML;
}
function loadClasses(callback) {
fetchJson(apiClasses).then(function(r) {
var list = (r.ok && r.data && r.data.data) ? r.data.data : [];
classesList = list;
[filterClass, formClass].forEach(function(sel, idx) {
if (!sel) return;
var keepFirst = sel.options.length > 0 ? sel.options[0] : null;
sel.innerHTML = '';
if (keepFirst) sel.appendChild(keepFirst);
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;
sel.appendChild(opt);
});
});
if (callback) callback();
}).catch(function() {
showToast('Gagal memuat kelas', 'error');
});
}
function loadViolationsMaster() {
fetchJson(apiViolationsMaster).then(function(r) {
if (!r.ok) { showToast('Gagal memuat daftar pelanggaran', 'error'); return; }
violationsMaster = (r.data && r.data.data) ? r.data.data : [];
violationItemsByCategory = {};
// Isi dropdown kategori
if (formViolationCategory) {
var firstCat = formViolationCategory.options[0];
formViolationCategory.innerHTML = '';
if (firstCat) formViolationCategory.appendChild(firstCat);
violationsMaster.forEach(function(cat) {
if (!Array.isArray(cat.items)) return;
violationItemsByCategory[cat.id] = cat.items;
var opt = document.createElement('option');
opt.value = cat.id;
opt.textContent = '[' + (cat.code || '') + '] ' + (cat.name || '');
formViolationCategory.appendChild(opt);
});
}
// Reset dropdown jenis pelanggaran sampai kategori dipilih
if (formViolation) {
var firstV = formViolation.options[0];
formViolation.innerHTML = '';
if (firstV) formViolation.appendChild(firstV);
}
}).catch(function() {
showToast('Gagal memuat daftar pelanggaran', 'error');
});
}
function loadDisciplineLevels() {
fetchJson(apiDisciplineLevels).then(function(r) {
if (!r.ok) { showToast('Gagal memuat level disiplin', 'error'); return; }
disciplineLevels = (r.data && r.data.data) ? r.data.data : [];
}).catch(function() {
showToast('Gagal memuat level disiplin', 'error');
});
}
function loadStudentsForClass(classId, callback) {
if (!classId) {
if (formStudent) {
var first = formStudent.options[0];
formStudent.innerHTML = '';
if (first) formStudent.appendChild(first);
}
if (callback) callback([]);
return;
}
var url = apiStudents + '?class_id=' + encodeURIComponent(classId) + '&per_page=200';
fetchJson(url).then(function(r) {
var list = (r.ok && r.data && r.data.data) ? r.data.data : [];
if (formStudent) {
var first = formStudent.options[0];
formStudent.innerHTML = '';
if (first) formStudent.appendChild(first);
list.forEach(function(s) {
var opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name + (s.nisn ? ' (' + s.nisn + ')' : '');
formStudent.appendChild(opt);
});
}
if (callback) callback(list);
}).catch(function() {
showToast('Gagal memuat siswa', 'error');
});
}
function formatDateTime(dt) {
if (!dt) return '';
var d = new Date(dt);
if (isNaN(d.getTime())) return dt;
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function findLevelForScore(score) {
if (!Array.isArray(disciplineLevels) || !disciplineLevels.length) return null;
for (var i = 0; i < disciplineLevels.length; i++) {
var lvl = disciplineLevels[i];
var min = lvl.min_score || 0;
var max = (lvl.max_score != null) ? lvl.max_score : null;
if (score >= min && (max === null || score <= max)) {
return lvl;
}
}
return null;
}
function applyRecap(list) {
if (!Array.isArray(list) || list.length === 0) {
recapEmpty.classList.remove('hidden');
recapTableWrap.classList.add('hidden');
recapTbody.innerHTML = '';
return;
}
var byStudent = {};
list.forEach(function(r) {
var sid = r.student_id;
if (!byStudent[sid]) {
byStudent[sid] = {
student_name: r.student_name,
class_label: r.class_label || '-',
total_score: 0,
count: 0
};
}
byStudent[sid].total_score += (r.violation_score || 0);
byStudent[sid].count += 1;
});
var rows = Object.keys(byStudent).map(function(id) {
var x = byStudent[id];
return {
student_name: x.student_name,
class_label: x.class_label,
total_score: x.total_score,
count: x.count
};
});
rows.sort(function(a, b) { return b.total_score - a.total_score; });
recapTbody.innerHTML = '';
rows.forEach(function(x) {
var lvl = findLevelForScore(x.total_score);
var levelText = lvl ? (lvl.title + ' — ' + (lvl.school_action || '')) : '-';
var tr = document.createElement('tr');
tr.innerHTML =
'<td class="px-3 py-2 font-medium">' + escapeHtml(x.student_name) + '</td>' +
'<td class="px-3 py-2">' + escapeHtml(x.class_label || '-') + '</td>' +
'<td class="px-3 py-2 text-right text-red-600 dark:text-red-400 font-semibold">' + x.total_score + '</td>' +
'<td class="px-3 py-2 text-right text-gray-600 dark:text-gray-400">' + x.count + '</td>' +
'<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300 max-w-xs whitespace-pre-line">' + escapeHtml(levelText) + '</td>';
recapTbody.appendChild(tr);
});
recapEmpty.classList.add('hidden');
recapTableWrap.classList.remove('hidden');
}
function loadStudentViolations() {
vLoading.classList.remove('hidden');
vError.classList.add('hidden');
vContent.classList.add('hidden');
var params = [];
var classVal = filterClass.value;
if (classVal) params.push('class_id=' + encodeURIComponent(classVal));
if (filterFrom.value) params.push('from_date=' + encodeURIComponent(filterFrom.value));
if (filterTo.value) params.push('to_date=' + encodeURIComponent(filterTo.value));
var url = apiStudentViolations;
if (params.length) url += '?' + params.join('&');
fetchJson(url).then(function(r) {
vLoading.classList.add('hidden');
if (!r.ok) {
vError.textContent = (r.data && r.data.message) ? r.data.message : 'Gagal memuat data';
vError.classList.remove('hidden');
return;
}
var list = (r.data && r.data.data) ? r.data.data : [];
// Recap
applyRecap(list);
vTbody.innerHTML = '';
if (!list.length) {
vContent.classList.remove('hidden');
return;
}
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.innerHTML =
'<td class="px-3 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(row.occurred_at)) + '</td>' +
'<td class="px-3 py-2">' + escapeHtml(row.student_name) + '</td>' +
'<td class="px-3 py-2">' + escapeHtml(row.class_label || '-') + '</td>' +
'<td class="px-3 py-2">' + '[' + escapeHtml(row.category_code || '') + '] ' + escapeHtml(row.violation_title || '') + '</td>' +
'<td class="px-3 py-2 text-right text-red-600 dark:text-red-400">' + (row.violation_score || 0) + '</td>' +
'<td class="px-3 py-2">' + escapeHtml(row.reported_by_name || '-') + '</td>';
vTbody.appendChild(tr);
});
vContent.classList.remove('hidden');
}).catch(function() {
vLoading.classList.add('hidden');
vError.textContent = 'Gagal memuat data';
vError.classList.remove('hidden');
});
var label = '';
if (filterFrom.value || filterTo.value) {
label = (filterFrom.value || '') + ' s/d ' + (filterTo.value || '');
}
recapRange.textContent = label;
}
btnFilter.addEventListener('click', loadStudentViolations);
if (formViolationCategory) {
formViolationCategory.addEventListener('change', function() {
var catId = formViolationCategory.value ? parseInt(formViolationCategory.value, 10) : 0;
if (!formViolation) return;
var first = formViolation.options[0];
formViolation.innerHTML = '';
if (first) formViolation.appendChild(first);
if (!catId || !violationItemsByCategory[catId]) {
return;
}
violationItemsByCategory[catId].forEach(function(v) {
var opt = document.createElement('option');
opt.value = v.id;
opt.textContent = v.title + ' (' + v.score + ' poin)';
formViolation.appendChild(opt);
});
});
}
formClass.addEventListener('change', function() {
var cid = formClass.value;
loadStudentsForClass(cid);
});
btnSave.addEventListener('click', function() {
var studentId = formStudent.value ? parseInt(formStudent.value, 10) : 0;
var violationId = formViolation.value ? parseInt(formViolation.value, 10) : 0;
var occurredAt = formOccurredAt.value;
var notes = formNotes.value.trim() || null;
if (!formClass.value) {
showToast('Pilih kelas terlebih dahulu', 'error');
return;
}
if (!studentId) {
showToast('Pilih siswa terlebih dahulu', 'error');
return;
}
if (!violationId) {
showToast('Pilih jenis pelanggaran', 'error');
return;
}
var payload = {
student_id: studentId,
violation_id: violationId
};
if (occurredAt) {
payload.occurred_at = occurredAt.replace('T', ' ') + ':00';
}
if (notes) payload.notes = notes;
btnSave.disabled = true;
fetch(apiStudentViolations, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload)
}).then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); })
.then(function(r) {
btnSave.disabled = false;
if (!r.ok) {
showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan pelanggaran', 'error');
return;
}
showToast('Pelanggaran berhasil dicatat');
formViolation.value = '';
formOccurredAt.value = '';
formNotes.value = '';
loadStudentViolations();
}).catch(function() {
btnSave.disabled = false;
showToast('Gagal menyimpan pelanggaran', 'error');
});
});
// Init defaults
var today = new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var todayStr = today.getFullYear() + '-' + pad(today.getMonth() + 1) + '-' + pad(today.getDate());
filterFrom.value = todayStr;
filterTo.value = todayStr;
loadClasses(function() {
loadViolationsMaster();
loadDisciplineLevels();
loadStudentViolations();
});
})();
</script>

View File

@@ -0,0 +1,270 @@
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold">Aturan Poin Pelanggaran</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1 text-sm">
Kelola daftar jenis pelanggaran dan skor poinnya. Perubahan akan otomatis mempengaruhi perhitungan total poin siswa.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Form -->
<div class="space-y-6 lg:col-span-1">
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm space-y-3">
<h2 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Form Pelanggaran</h2>
<input type="hidden" id="form-id" value="">
<div class="space-y-3 text-sm">
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Kategori</label>
<select id="form-category" 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="">Pilih kategori</option>
</select>
</div>
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Judul Pelanggaran</label>
<input type="text" id="form-title" 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">Skor Poin</label>
<input type="number" id="form-score" 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" min="0">
</div>
<div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" id="form-active" class="rounded border-gray-300 text-primary focus:ring-primary" checked>
<span>Aktif</span>
</label>
</div>
<div>
<label class="block mb-1 text-gray-600 dark:text-gray-300">Deskripsi (opsional)</label>
<textarea id="form-description" rows="3" 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"></textarea>
</div>
<div class="flex gap-2">
<button type="button" id="btn-save-violation-master" class="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover">
Simpan
</button>
<button type="button" id="btn-reset-violation-master" class="px-3 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
Reset
</button>
</div>
</div>
</div>
</div>
<!-- List -->
<div class="lg:col-span-2 space-y-4">
<div class="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">Daftar Pelanggaran</h2>
</div>
<div id="master-loading" class="py-10 text-center text-gray-500 dark:text-gray-400">Memuat…</div>
<div id="master-error" class="hidden py-3 text-sm text-red-600 dark:text-red-400"></div>
<div id="master-content" 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">Kategori</th>
<th class="px-3 py-2 font-medium">Judul</th>
<th class="px-3 py-2 font-medium text-right">Skor</th>
<th class="px-3 py-2 font-medium">Status</th>
<th class="px-3 py-2 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody id="master-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div id="discipline-settings-toast" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiViolationsMaster = baseUrl + '/api/discipline/violations';
var apiViolationsAdmin = baseUrl + '/api/discipline/violations-admin';
var formId = document.getElementById('form-id');
var formCategory = document.getElementById('form-category');
var formTitle = document.getElementById('form-title');
var formScore = document.getElementById('form-score');
var formDescription = document.getElementById('form-description');
var formActive = document.getElementById('form-active');
var btnSave = document.getElementById('btn-save-violation-master');
var btnReset = document.getElementById('btn-reset-violation-master');
var masterLoading = document.getElementById('master-loading');
var masterError = document.getElementById('master-error');
var masterContent = document.getElementById('master-content');
var masterTbody = document.getElementById('master-tbody');
var toastContainer = document.getElementById('discipline-settings-toast');
var masterData = [];
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 4000);
}
function fetchJson(url) {
return fetch(url, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); });
}
function escapeHtml(str) {
if (str == null) return '';
var d = document.createElement('div'); d.textContent = str; return d.innerHTML;
}
function resetForm() {
formId.value = '';
formCategory.value = '';
formTitle.value = '';
formScore.value = '';
formDescription.value = '';
formActive.checked = true;
}
btnReset.addEventListener('click', function() {
resetForm();
});
function loadMaster() {
masterLoading.classList.remove('hidden');
masterError.classList.add('hidden');
masterContent.classList.add('hidden');
fetchJson(apiViolationsMaster).then(function(r) {
masterLoading.classList.add('hidden');
if (!r.ok) {
masterError.textContent = (r.data && r.data.message) ? r.data.message : 'Gagal memuat data';
masterError.classList.remove('hidden');
return;
}
var cats = (r.data && r.data.data) ? r.data.data : [];
masterData = [];
// isi kategori dropdown
var first = formCategory.options[0];
formCategory.innerHTML = '';
if (first) formCategory.appendChild(first);
cats.forEach(function(cat) {
var opt = document.createElement('option');
opt.value = cat.id;
opt.textContent = '[' + (cat.code || '') + '] ' + (cat.name || '');
formCategory.appendChild(opt);
(cat.items || []).forEach(function(v) {
masterData.push({
id: v.id,
category_id: cat.id,
category_code: cat.code,
category_name: cat.name,
title: v.title,
description: v.description || '',
score: v.score,
is_active: 1 // dari endpoint master kita asumsikan aktif saja
});
});
});
renderTable();
masterContent.classList.remove('hidden');
}).catch(function() {
masterLoading.classList.add('hidden');
masterError.textContent = 'Gagal memuat data';
masterError.classList.remove('hidden');
});
}
function renderTable() {
masterTbody.innerHTML = '';
if (!masterData.length) return;
masterData.forEach(function(v) {
var tr = document.createElement('tr');
var statusLabel = v.is_active ? 'Aktif' : 'Nonaktif';
var statusClass = v.is_active ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400';
tr.innerHTML =
'<td class="px-3 py-2">' + escapeHtml('[' + (v.category_code || '') + '] ' + (v.category_name || '')) + '</td>' +
'<td class="px-3 py-2">' + escapeHtml(v.title) + '</td>' +
'<td class="px-3 py-2 text-right">' + (v.score || 0) + '</td>' +
'<td class="px-3 py-2 ' + statusClass + '">' + statusLabel + '</td>' +
'<td class="px-3 py-2 text-right">' +
'<button type="button" class="btn-edit text-primary text-xs sm:text-sm px-2 py-1 rounded hover:bg-primary/10" data-id="' + v.id + '">Edit</button>' +
'</td>';
masterTbody.appendChild(tr);
});
masterTbody.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = parseInt(btn.getAttribute('data-id'), 10);
var row = masterData.find(function(x) { return x.id === id; });
if (!row) return;
formId.value = row.id;
formCategory.value = row.category_id;
formTitle.value = row.title || '';
formScore.value = row.score || 0;
formDescription.value = row.description || '';
formActive.checked = !!row.is_active;
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
}
btnSave.addEventListener('click', function() {
var id = formId.value ? parseInt(formId.value, 10) : null;
var categoryId = formCategory.value ? parseInt(formCategory.value, 10) : 0;
var title = formTitle.value.trim();
var score = parseInt(formScore.value, 10);
var description = formDescription.value.trim();
var isActive = formActive.checked ? 1 : 0;
if (!categoryId) { showToast('Pilih kategori', 'error'); return; }
if (!title) { showToast('Judul pelanggaran wajib diisi', 'error'); return; }
if (isNaN(score)) { showToast('Skor wajib diisi', 'error'); return; }
var payload = {
category_id: categoryId,
title: title,
score: score,
is_active: isActive
};
if (description) payload.description = description;
btnSave.disabled = true;
var url = apiViolationsAdmin;
var method = 'POST';
if (id) {
url = apiViolationsAdmin + '/' + id;
method = 'PUT';
}
fetch(url, {
method: method,
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload)
}).then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); })
.then(function(r) {
btnSave.disabled = false;
if (!r.ok) {
showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan', 'error');
return;
}
showToast('Berhasil disimpan');
resetForm();
loadMaster();
}).catch(function() {
btnSave.disabled = false;
showToast('Gagal menyimpan', 'error');
});
});
loadMaster();
})();
</script>

View File

@@ -0,0 +1,224 @@
<div id="current-lesson-card" class="hidden rounded-2xl border-2 border-primary bg-primary/5 dark:bg-primary/10 border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-primary/10 dark:bg-primary/20">
<h2 class="text-lg font-semibold text-primary dark:text-primary-400">CURRENT LESSON</h2>
</div>
<div class="p-6 flex flex-wrap items-center justify-between gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Subject</p>
<p id="current-subject" class="font-medium"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Class</p>
<p id="current-class" class="font-medium"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Teacher</p>
<p id="current-teacher" class="font-medium"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Time</p>
<p id="current-time" class="font-medium"></p>
</div>
</div>
<a id="current-open-attendance" href="#" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:opacity-90">Open Attendance</a>
</div>
<div id="current-progress-wrap" class="hidden border-t border-gray-200 dark:border-gray-700 px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Live Attendance</p>
<div class="flex items-center gap-4 flex-wrap">
<div class="flex-1 min-w-[200px]">
<div class="h-3 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div id="current-progress-bar" class="h-full rounded-full bg-green-500 dark:bg-green-600 transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<div class="flex gap-6 text-sm">
<span><strong id="current-present">0</strong> hadir</span>
<span><strong id="current-late">0</strong> terlambat</span>
<span><strong id="current-absent">0</strong> tidak hadir</span>
<span class="text-gray-500 dark:text-gray-400"><span id="current-expected">0</span> siswa</span>
</div>
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold">Realtime Attendance</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">Live stream via Server-Sent Events. Newest at top.</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">Time</th>
<th class="px-6 py-3 font-medium">Student</th>
<th class="px-6 py-3 font-medium">Class</th>
<th class="px-6 py-3 font-medium">Subject</th>
<th class="px-6 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody id="attendance-tbody" class="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">Connecting to stream…</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var currentApiUrl = baseUrl + '/api/dashboard/schedules/current';
var progressApiUrl = baseUrl + '/api/dashboard/attendance/progress/current';
var currentCard = document.getElementById('current-lesson-card');
var currentSubject = document.getElementById('current-subject');
var currentClass = document.getElementById('current-class');
var currentTeacher = document.getElementById('current-teacher');
var currentTime = document.getElementById('current-time');
var currentOpenBtn = document.getElementById('current-open-attendance');
var progressWrap = document.getElementById('current-progress-wrap');
var progressBar = document.getElementById('current-progress-bar');
var elPresent = document.getElementById('current-present');
var elLate = document.getElementById('current-late');
var elAbsent = document.getElementById('current-absent');
var elExpected = document.getElementById('current-expected');
var progressInterval = null;
function updateProgress() {
fetch(progressApiUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok || !r.data || !r.data.data) return;
var d = r.data.data;
if (!d.active) {
if (progressWrap) progressWrap.classList.add('hidden');
return;
}
if (progressWrap) progressWrap.classList.remove('hidden');
var expected = d.expected_total || 0;
var present = d.present_total || 0;
var late = d.late_total || 0;
var absent = d.absent_total || 0;
var pct = expected > 0 ? Math.round((present / expected) * 100) : 0;
if (progressBar) progressBar.style.width = pct + '%';
if (elPresent) elPresent.textContent = present;
if (elLate) elLate.textContent = late;
if (elAbsent) elAbsent.textContent = absent;
if (elExpected) elExpected.textContent = expected;
})
.catch(function() {});
}
fetch(currentApiUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok || !r.data || !r.data.data) return;
var d = r.data.data;
if (d.is_active_now && d.schedule_id) {
currentSubject.textContent = d.subject_name || '';
currentClass.textContent = d.class_name || '';
currentTeacher.textContent = d.teacher_name || '';
currentTime.textContent = (d.start_time && d.end_time) ? (d.start_time + ' ' + d.end_time) : '';
currentOpenBtn.href = baseUrl + '/dashboard/attendance/report/' + d.schedule_id;
currentCard.classList.remove('hidden');
updateProgress();
progressInterval = setInterval(updateProgress, 5000);
}
})
.catch(function() {});
var tbody = document.getElementById('attendance-tbody');
var streamUrl = baseUrl + '/api/dashboard/stream';
var afterId = 0;
var placeholderRow = null;
var connected = false;
function badgeClass(status) {
var s = (status || '').toUpperCase();
if (s === 'PRESENT') return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
if (s === 'LATE') return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
if (s === 'OUTSIDE_ZONE') return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
if (s === 'NO_SCHEDULE' || s === 'INVALID_DEVICE') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
}
function formatTime(iso) {
if (!iso) return '';
var d = new Date(iso);
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function addRow(data) {
if (placeholderRow && placeholderRow.parentNode) {
placeholderRow.remove();
placeholderRow = null;
}
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
tr.setAttribute('data-id', data.id);
var statusClass = badgeClass(data.status);
tr.innerHTML =
'<td class="px-6 py-3 text-sm text-gray-600 dark:text-gray-400">' + formatTime(data.checkin_at) + '</td>' +
'<td class="px-6 py-3 font-medium">' + escapeHtml(data.student_name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(data.class_name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(data.subject) + '</td>' +
'<td class="px-6 py-3"><span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium ' + statusClass + '">' + escapeHtml(data.status) + '</span></td>';
tbody.insertBefore(tr, tbody.firstChild);
if (data.id > afterId) afterId = data.id;
}
function escapeHtml(str) {
if (str == null) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function setPlaceholder(msg) {
if (placeholderRow && placeholderRow.parentNode) placeholderRow.remove();
placeholderRow = document.createElement('tr');
placeholderRow.innerHTML = '<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">' + escapeHtml(msg) + '</td>';
tbody.appendChild(placeholderRow);
}
function connect() {
var url = streamUrl + (afterId ? '?after_id=' + afterId : '');
var es = new EventSource(url);
es.addEventListener('attendance', function(e) {
try {
var data = JSON.parse(e.data);
if (data && data.id) addRow(data);
} catch (err) {}
});
es.addEventListener('heartbeat', function() {
if (!connected) {
connected = true;
setPlaceholder('No attendance records yet. Waiting for new check-ins…');
}
});
es.addEventListener('timeout', function() {
es.close();
connected = false;
setPlaceholder('Stream ended. Reconnecting…');
setTimeout(connect, 3000);
});
es.onerror = function() {
es.close();
connected = false;
setPlaceholder('Connection lost. Reconnecting…');
setTimeout(connect, 3000);
};
}
connect();
if (typeof window.addEventListener === 'function') {
window.addEventListener('beforeunload', function() {
if (progressInterval) clearInterval(progressInterval);
});
}
})();
</script>

View File

@@ -0,0 +1,227 @@
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold">Jam Pelajaran</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Kelola slot jam pelajaran (urutan, jam mulai, jam selesai).</p>
</div>
<button type="button" id="btn-add-slot" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors">
<i class="bx bx-plus text-xl"></i>
<span>Tambah Jam</span>
</button>
</div>
<div id="slots-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">Memuat…</div>
<div id="slots-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="slots-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">No</th>
<th class="px-6 py-3 font-medium">Jam Mulai</th>
<th class="px-6 py-3 font-medium">Jam Selesai</th>
<th class="px-6 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody id="slots-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="modal-slot" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-slot-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div class="p-6">
<h2 id="modal-slot-title" class="text-lg font-semibold mb-4">Tambah Jam Pelajaran</h2>
<form id="form-slot">
<input type="hidden" id="form-slot-id" value="">
<div class="space-y-4">
<div>
<label for="form-slot-number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Urutan <span class="text-red-500">*</span></label>
<input type="number" id="form-slot-number" name="slot_number" required min="1" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="1">
</div>
<div>
<label for="form-slot-start" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Jam Mulai <span class="text-red-500">*</span></label>
<input type="text" id="form-slot-start" name="start_time" required class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="07:00:00">
</div>
<div>
<label for="form-slot-end" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Jam Selesai <span class="text-red-500">*</span></label>
<input type="text" id="form-slot-end" name="end_time" required class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="07:45:00">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" id="form-slot-submit" class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover">Simpan</button>
<button type="button" id="form-slot-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Delete confirmation -->
<div id="modal-slot-delete" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-slot-delete-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-sm rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold mb-2">Hapus Jam Pelajaran</h2>
<p id="modal-slot-delete-message" class="text-gray-600 dark:text-gray-400 mb-6">Yakin ingin menghapus?</p>
<div class="flex gap-3">
<button type="button" id="modal-slot-delete-confirm" class="flex-1 px-4 py-2.5 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700">Hapus</button>
<button type="button" id="modal-slot-delete-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</div>
</div>
</div>
<div id="toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiSlots = baseUrl + '/api/academic/lesson-slots';
var loading = document.getElementById('slots-loading');
var errorEl = document.getElementById('slots-error');
var content = document.getElementById('slots-content');
var tbody = document.getElementById('slots-tbody');
var btnAdd = document.getElementById('btn-add-slot');
var modal = document.getElementById('modal-slot');
var modalTitle = document.getElementById('modal-slot-title');
var form = document.getElementById('form-slot');
var formId = document.getElementById('form-slot-id');
var formNumber = document.getElementById('form-slot-number');
var formStart = document.getElementById('form-slot-start');
var formEnd = document.getElementById('form-slot-end');
var formSubmit = document.getElementById('form-slot-submit');
var formCancel = document.getElementById('form-slot-cancel');
var modalBackdrop = document.getElementById('modal-slot-backdrop');
var modalDelete = document.getElementById('modal-slot-delete');
var modalDeleteMsg = document.getElementById('modal-slot-delete-message');
var modalDeleteConfirm = document.getElementById('modal-slot-delete-confirm');
var modalDeleteCancel = document.getElementById('modal-slot-delete-cancel');
var modalDeleteBackdrop = document.getElementById('modal-slot-delete-backdrop');
var toastContainer = document.getElementById('toast-container');
var deleteTargetId = null;
function showLoading() { loading.classList.remove('hidden'); errorEl.classList.add('hidden'); content.classList.add('hidden'); }
function showError(msg) { loading.classList.add('hidden'); content.classList.add('hidden'); errorEl.classList.remove('hidden'); errorEl.textContent = msg; }
function showContent() { loading.classList.add('hidden'); errorEl.classList.add('hidden'); content.classList.remove('hidden'); }
function escapeHtml(str) { if (str == null) return ''; var d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
}
var fetchOpts = { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } };
function postOpts(body) { return { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function putOpts(body) { return { method: 'PUT', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function deleteOpts() { return { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } }; }
function loadSlots() {
showLoading();
fetch(apiSlots, fetchOpts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) { showError(r.data && r.data.message ? r.data.message : 'Gagal memuat'); return; }
var list = (r.data && r.data.data) ? r.data.data : (Array.isArray(r.data) ? r.data : []);
tbody.innerHTML = '';
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
tr.innerHTML =
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.slot_number) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.start_time) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.end_time) + '</td>' +
'<td class="px-6 py-3 text-right">' +
'<button type="button" class="btn-edit px-3 py-1.5 rounded-lg text-primary hover:bg-primary/10" data-id="' + row.id + '" data-slot-number="' + row.slot_number + '" data-start="' + escapeHtml(row.start_time) + '" data-end="' + escapeHtml(row.end_time) + '">Edit</button> ' +
'<button type="button" class="btn-delete px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20" data-id="' + row.id + '" data-label="Jam ' + row.slot_number + '">Hapus</button>' +
'</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
formId.value = btn.getAttribute('data-id');
formNumber.value = btn.getAttribute('data-slot-number') || '';
formStart.value = btn.getAttribute('data-start') || '';
formEnd.value = btn.getAttribute('data-end') || '';
modalTitle.textContent = 'Edit Jam Pelajaran';
modal.classList.remove('hidden');
});
});
tbody.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteTargetId = parseInt(btn.getAttribute('data-id'), 10);
modalDeleteMsg.textContent = 'Yakin ingin menghapus ' + (btn.getAttribute('data-label') || '') + '?';
modalDelete.classList.remove('hidden');
});
});
showContent();
})
.catch(function() { showError('Gagal memuat data'); });
}
btnAdd.addEventListener('click', function() {
formId.value = ''; formNumber.value = ''; formStart.value = ''; formEnd.value = '';
modalTitle.textContent = 'Tambah Jam Pelajaran';
modal.classList.remove('hidden');
});
form.addEventListener('submit', function(e) {
e.preventDefault();
var id = formId.value ? parseInt(formId.value, 10) : null;
var payload = {
slot_number: parseInt(formNumber.value, 10) || 1,
start_time: formStart.value.trim(),
end_time: formEnd.value.trim()
};
formSubmit.disabled = true;
var url = apiSlots;
var opts = postOpts(payload);
if (id) { url = apiSlots + '/' + id; opts = putOpts(payload); }
fetch(url, opts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
formSubmit.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan', 'error'); return; }
showToast(id ? 'Berhasil diubah' : 'Berhasil ditambahkan');
modal.classList.add('hidden');
loadSlots();
})
.catch(function() { formSubmit.disabled = false; showToast('Gagal menyimpan', 'error'); });
});
formCancel.addEventListener('click', function() { modal.classList.add('hidden'); });
modalBackdrop.addEventListener('click', function() { modal.classList.add('hidden'); });
modalDeleteConfirm.addEventListener('click', function() {
if (!deleteTargetId) return;
var id = deleteTargetId;
modalDeleteConfirm.disabled = true;
fetch(apiSlots + '/' + id, deleteOpts())
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
modalDeleteConfirm.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menghapus', 'error'); return; }
showToast('Berhasil dihapus');
modalDelete.classList.add('hidden');
deleteTargetId = null;
loadSlots();
})
.catch(function() { modalDeleteConfirm.disabled = false; showToast('Gagal menghapus', 'error'); });
});
modalDeleteCancel.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
modalDeleteBackdrop.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
loadSlots();
})();
</script>

View File

@@ -0,0 +1,339 @@
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold">Portal Orang Tua</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1 text-sm">
Lihat data absensi dan pelanggaran anak Anda.
</p>
</div>
</div>
<!-- Pilih Anak -->
<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">Pilih Anak</h2>
<select id="select-child" 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="">-- Pilih anak --</option>
</select>
</div>
<!-- Data Anak -->
<div id="child-data-section" class="hidden space-y-6">
<!-- Profil Anak -->
<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">Profil Anak</h2>
<div id="child-profile" class="text-sm text-gray-600 dark:text-gray-300 space-y-2">
<!-- Akan diisi via JS -->
</div>
</div>
<!-- Absensi -->
<div class="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">Riwayat Absensi</h2>
</div>
<div class="mb-3 space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block mb-1 text-xs text-gray-600 dark:text-gray-300">Dari tanggal</label>
<input type="date" id="attendance-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-xs">
</div>
<div>
<label class="block mb-1 text-xs text-gray-600 dark:text-gray-300">Sampai tanggal</label>
<input type="date" id="attendance-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-xs">
</div>
</div>
<button type="button" id="btn-load-attendance" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary-hover">
Muat Absensi
</button>
</div>
<div id="attendance-content" class="text-sm">
<p id="attendance-empty" class="text-gray-500 dark:text-gray-400 text-xs">Pilih anak dan klik "Muat Absensi" untuk melihat data.</p>
<div id="attendance-table-wrap" class="hidden overflow-x-auto">
<table class="w-full text-left text-xs">
<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">Mata Pelajaran</th>
<th class="px-3 py-2 font-medium">Guru</th>
<th class="px-3 py-2 font-medium">Waktu Masuk</th>
<th class="px-3 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody id="attendance-tbody" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Akan diisi via JS -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Pelanggaran -->
<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">Data Pelanggaran</h2>
<div id="discipline-summary" class="mb-4 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 text-sm">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-gray-600 dark:text-gray-400 text-xs">Total Poin</div>
<div id="discipline-total-points" class="text-lg font-semibold text-gray-900 dark:text-gray-100">-</div>
</div>
<div>
<div class="text-gray-600 dark:text-gray-400 text-xs">Jumlah Kasus</div>
<div id="discipline-violation-count" class="text-lg font-semibold text-gray-900 dark:text-gray-100">-</div>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
<div class="text-gray-600 dark:text-gray-400 text-xs">Level / Tindakan</div>
<div id="discipline-level" class="text-sm font-medium text-gray-900 dark:text-gray-100 mt-1">-</div>
</div>
</div>
<div id="discipline-content" class="text-sm">
<p id="discipline-empty" class="text-gray-500 dark:text-gray-400 text-xs">Memuat data pelanggaran...</p>
<div id="discipline-table-wrap" class="hidden overflow-x-auto">
<table class="w-full text-left text-xs">
<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">Kategori</th>
<th class="px-3 py-2 font-medium">Jenis Pelanggaran</th>
<th class="px-3 py-2 font-medium text-right">Poin</th>
<th class="px-3 py-2 font-medium">Catatan</th>
</tr>
</thead>
<tbody id="discipline-tbody" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Akan diisi via JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
var apiBase = '<?= base_url('api/parent') ?>';
var selectChild = document.getElementById('select-child');
var childDataSection = document.getElementById('child-data-section');
var childProfile = document.getElementById('child-profile');
var attendanceFrom = document.getElementById('attendance-from');
var attendanceTo = document.getElementById('attendance-to');
var btnLoadAttendance = document.getElementById('btn-load-attendance');
var attendanceEmpty = document.getElementById('attendance-empty');
var attendanceTableWrap = document.getElementById('attendance-table-wrap');
var attendanceTbody = document.getElementById('attendance-tbody');
var disciplineSummary = document.getElementById('discipline-summary');
var disciplineTotalPoints = document.getElementById('discipline-total-points');
var disciplineViolationCount = document.getElementById('discipline-violation-count');
var disciplineLevel = document.getElementById('discipline-level');
var disciplineEmpty = document.getElementById('discipline-empty');
var disciplineTableWrap = document.getElementById('discipline-table-wrap');
var disciplineTbody = document.getElementById('discipline-tbody');
var children = [];
var currentChildId = null;
function fetchJson(url) {
return fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' }
}).then(function(r) {
return r.json().then(function(data) {
return { ok: r.ok, status: r.status, data: data };
});
});
}
function showToast(msg, type) {
type = type || 'info';
var bg = type === 'error' ? 'bg-red-500' : type === 'success' ? 'bg-green-500' : 'bg-blue-500';
var toast = document.createElement('div');
toast.className = 'fixed top-4 right-4 ' + bg + ' text-white px-4 py-2 rounded-lg shadow-lg z-50 text-sm';
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(function() {
toast.remove();
}, 3000);
}
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 loadChildren() {
fetchJson(apiBase + '/children').then(function(r) {
if (!r.ok) {
showToast('Gagal memuat daftar anak', 'error');
return;
}
children = (r.data && r.data.data) ? r.data.data : [];
selectChild.innerHTML = '<option value="">-- Pilih anak --</option>';
children.forEach(function(child) {
var opt = document.createElement('option');
opt.value = child.id;
opt.textContent = child.name + (child.class_label ? ' (' + child.class_label + ')' : '');
selectChild.appendChild(opt);
});
if (children.length === 0) {
showToast('Tidak ada data anak yang terhubung', 'info');
}
}).catch(function() {
showToast('Gagal memuat daftar anak', 'error');
});
}
function loadChildProfile(childId) {
var child = children.find(function(c) { return c.id == childId; });
if (!child) return;
childProfile.innerHTML =
'<div><strong>Nama:</strong> ' + (child.name || '-') + '</div>' +
'<div><strong>NISN:</strong> ' + (child.nisn || '-') + '</div>' +
'<div><strong>Kelas:</strong> ' + (child.class_label || '-') + '</div>' +
'<div><strong>Jenis Kelamin:</strong> ' + (child.gender === 'L' ? 'Laki-laki' : child.gender === 'P' ? 'Perempuan' : '-') + '</div>' +
'<div><strong>Hubungan:</strong> ' + (child.relationship || '-') + '</div>';
}
function loadAttendance() {
if (!currentChildId) {
showToast('Pilih anak terlebih dahulu', 'error');
return;
}
var from = attendanceFrom.value || '';
var to = attendanceTo.value || '';
var url = apiBase + '/attendance?student_id=' + encodeURIComponent(currentChildId);
if (from) url += '&from=' + encodeURIComponent(from);
if (to) url += '&to=' + encodeURIComponent(to);
btnLoadAttendance.disabled = true;
btnLoadAttendance.textContent = 'Memuat...';
fetchJson(url).then(function(r) {
btnLoadAttendance.disabled = false;
btnLoadAttendance.textContent = 'Muat Absensi';
if (!r.ok) {
showToast('Gagal memuat data absensi', 'error');
return;
}
var list = (r.data && r.data.data) ? r.data.data : [];
if (list.length === 0) {
attendanceEmpty.classList.remove('hidden');
attendanceTableWrap.classList.add('hidden');
attendanceTbody.innerHTML = '';
return;
}
attendanceEmpty.classList.add('hidden');
attendanceTableWrap.classList.remove('hidden');
attendanceTbody.innerHTML = '';
list.forEach(function(item) {
var tr = document.createElement('tr');
var statusClass = item.status === 'PRESENT' ? 'text-green-600 dark:text-green-400' :
item.status === 'LATE' ? 'text-yellow-600 dark:text-yellow-400' :
'text-red-600 dark:text-red-400';
var statusText = item.status === 'PRESENT' ? 'Hadir' :
item.status === 'LATE' ? 'Terlambat' :
item.status === 'OUTSIDE_ZONE' ? 'Di Luar Zona' :
item.status === 'NO_SCHEDULE' ? 'Tidak Ada Jadwal' :
item.status === 'INVALID_DEVICE' ? 'Device Tidak Valid' :
item.status || '-';
tr.innerHTML =
'<td class="px-3 py-2">' + formatDate(item.attendance_date) + '</td>' +
'<td class="px-3 py-2">' + (item.subject_name || '-') + '</td>' +
'<td class="px-3 py-2">' + (item.teacher_name || '-') + '</td>' +
'<td class="px-3 py-2">' + (item.checkin_at ? formatDateTime(item.checkin_at) : '-') + '</td>' +
'<td class="px-3 py-2"><span class="' + statusClass + '">' + statusText + '</span></td>';
attendanceTbody.appendChild(tr);
});
}).catch(function() {
btnLoadAttendance.disabled = false;
btnLoadAttendance.textContent = 'Muat Absensi';
showToast('Gagal memuat data absensi', 'error');
});
}
function loadDiscipline() {
if (!currentChildId) return;
fetchJson(apiBase + '/discipline?student_id=' + encodeURIComponent(currentChildId)).then(function(r) {
if (!r.ok) {
showToast('Gagal memuat data pelanggaran', 'error');
disciplineEmpty.classList.remove('hidden');
disciplineTableWrap.classList.add('hidden');
return;
}
var data = (r.data && r.data.data) ? r.data.data : {};
var totalPoints = data.total_points || 0;
var violationCount = data.violation_count || 0;
var level = data.discipline_level || null;
var violations = data.violations || [];
disciplineTotalPoints.textContent = totalPoints;
disciplineViolationCount.textContent = violationCount;
if (level) {
disciplineLevel.textContent = level.title + (level.school_action ? ' — ' + level.school_action : '');
} else {
disciplineLevel.textContent = '-';
}
if (violations.length === 0) {
disciplineEmpty.classList.remove('hidden');
disciplineTableWrap.classList.add('hidden');
disciplineTbody.innerHTML = '';
return;
}
disciplineEmpty.classList.add('hidden');
disciplineTableWrap.classList.remove('hidden');
disciplineTbody.innerHTML = '';
violations.forEach(function(v) {
var tr = document.createElement('tr');
tr.innerHTML =
'<td class="px-3 py-2">' + formatDateTime(v.occurred_at) + '</td>' +
'<td class="px-3 py-2">' + (v.category_name || '-') + '</td>' +
'<td class="px-3 py-2">' + (v.violation_title || '-') + '</td>' +
'<td class="px-3 py-2 text-right">' + v.score + '</td>' +
'<td class="px-3 py-2">' + (v.notes || '-') + '</td>';
disciplineTbody.appendChild(tr);
});
}).catch(function() {
showToast('Gagal memuat data pelanggaran', 'error');
disciplineEmpty.classList.remove('hidden');
disciplineTableWrap.classList.add('hidden');
});
}
selectChild.addEventListener('change', function() {
var childId = parseInt(this.value);
if (!childId) {
currentChildId = null;
childDataSection.classList.add('hidden');
return;
}
currentChildId = childId;
childDataSection.classList.remove('hidden');
loadChildProfile(childId);
loadDiscipline();
// Set default date range untuk absensi (30 hari terakhir)
var today = new Date();
var lastMonth = new Date(today);
lastMonth.setDate(lastMonth.getDate() - 30);
attendanceTo.value = today.toISOString().split('T')[0];
attendanceFrom.value = lastMonth.toISOString().split('T')[0];
});
btnLoadAttendance.addEventListener('click', loadAttendance);
// Load children on page load
loadChildren();
})();
</script>

View File

@@ -0,0 +1,178 @@
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold">Pengaturan Presensi</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Satu pengaturan terpusat: <strong>koordinat sekolah</strong> (untuk semua absen) dan <strong>jadwal jam masuk &amp; jam pulang</strong>.</p>
</div>
<div id="presence-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Memuat…
</div>
<div id="presence-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="presence-form-wrap" class="hidden space-y-6">
<!-- Zona sekolah -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">Koordinat Sekolah (Zona Presensi)</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Semua absen (mobile &amp; device) hanya valid jika siswa berada di dalam radius ini.</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block mb-1 text-sm text-gray-600 dark:text-gray-300">Latitude</label>
<input type="number" step="0.00000001" id="zone-lat" 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-sm text-gray-600 dark:text-gray-300">Longitude</label>
<input type="number" step="0.00000001" id="zone-lng" 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-sm text-gray-600 dark:text-gray-300">Radius (meter)</label>
<input type="number" min="1" id="zone-radius" 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>
</div>
<!-- Jam masuk & pulang -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">Jadwal Masuk &amp; Pulang</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Window waktu untuk absen masuk dan absen pulang (format 24 jam).</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label class="block mb-1 text-sm text-gray-600 dark:text-gray-300">Jam masuk (mulai)</label>
<input type="time" id="time-masuk-start" 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-sm text-gray-600 dark:text-gray-300">Jam masuk (akhir)</label>
<input type="time" id="time-masuk-end" 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-sm text-gray-600 dark:text-gray-300">Jam pulang (mulai)</label>
<input type="time" id="time-pulang-start" 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-sm text-gray-600 dark:text-gray-300">Jam pulang (akhir)</label>
<input type="time" id="time-pulang-end" 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>
</div>
<div class="flex justify-end">
<button type="button" id="btn-save" class="inline-flex items-center justify-center px-5 py-2.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover">
Simpan Pengaturan
</button>
</div>
</div>
</div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiUrl = baseUrl + '/api/dashboard/presence-settings';
var loading = document.getElementById('presence-loading');
var errorEl = document.getElementById('presence-error');
var formWrap = document.getElementById('presence-form-wrap');
var zoneLat = document.getElementById('zone-lat');
var zoneLng = document.getElementById('zone-lng');
var zoneRadius = document.getElementById('zone-radius');
var timeMasukStart = document.getElementById('time-masuk-start');
var timeMasukEnd = document.getElementById('time-masuk-end');
var timePulangStart = document.getElementById('time-pulang-start');
var timePulangEnd = document.getElementById('time-pulang-end');
var btnSave = document.getElementById('btn-save');
function showError(msg) {
errorEl.textContent = msg || 'Terjadi kesalahan';
errorEl.classList.remove('hidden');
formWrap.classList.add('hidden');
}
function timeToHhMm(val) {
if (!val) return '';
if (val.length === 5 && val.indexOf(':') !== -1) return val;
var parts = (val + '').trim().split(/[:\s]/);
if (parts.length >= 2) return parts[0].padStart(2, '0') + ':' + parts[1].padStart(2, '0');
return val.substring(0, 5);
}
function hhMmToTimeInput(hhmmss) {
if (!hhmmss) return '';
var s = (hhmmss + '').trim();
if (s.length >= 5) return s.substring(0, 5);
return s;
}
function load() {
loading.classList.remove('hidden');
errorEl.classList.add('hidden');
formWrap.classList.add('hidden');
fetch(apiUrl, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(res) {
loading.classList.add('hidden');
if (res && res.success && res.data) {
var zone = res.data.zone || {};
var times = res.data.times || {};
zoneLat.value = zone.latitude != null ? zone.latitude : '';
zoneLng.value = zone.longitude != null ? zone.longitude : '';
zoneRadius.value = zone.radius_meters != null ? zone.radius_meters : '150';
timeMasukStart.value = hhMmToTimeInput(times.time_masuk_start);
timeMasukEnd.value = hhMmToTimeInput(times.time_masuk_end);
timePulangStart.value = hhMmToTimeInput(times.time_pulang_start);
timePulangEnd.value = hhMmToTimeInput(times.time_pulang_end);
formWrap.classList.remove('hidden');
} else {
showError(res && res.message ? res.message : 'Gagal memuat pengaturan');
}
})
.catch(function() {
loading.classList.add('hidden');
showError('Gagal memuat pengaturan (jaringan).');
});
}
btnSave.addEventListener('click', function() {
var lat = parseFloat(zoneLat.value);
var lng = parseFloat(zoneLng.value);
var radius = parseInt(zoneRadius.value, 10);
if (isNaN(lat) || isNaN(lng) || isNaN(radius) || radius < 1) {
alert('Isi koordinat sekolah (Latitude, Longitude, Radius) dengan benar.');
return;
}
var payload = {
zone: {
latitude: lat,
longitude: lng,
radius_meters: radius,
zone_name: 'Zona Sekolah'
},
times: {
time_masuk_start: timeToHhMm(timeMasukStart.value) + ':00',
time_masuk_end: timeToHhMm(timeMasukEnd.value) + ':00',
time_pulang_start: timeToHhMm(timePulangStart.value) + ':00',
time_pulang_end: timeToHhMm(timePulangEnd.value) + ':00'
}
};
btnSave.disabled = true;
fetch(apiUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
body: JSON.stringify(payload)
})
.then(function(r) { return r.json(); })
.then(function(res) {
btnSave.disabled = false;
if (res && res.success) {
if (typeof showToast === 'function') showToast('Pengaturan presensi berhasil disimpan', 'success');
else alert('Pengaturan berhasil disimpan.');
} else {
alert(res && res.message ? res.message : 'Gagal menyimpan');
}
})
.catch(function() {
btnSave.disabled = false;
alert('Gagal menyimpan (jaringan).');
});
});
load();
})();
</script>

View File

@@ -0,0 +1,279 @@
<?php
$classId = (int) ($classId ?? 0);
$className = $className ?? 'Kelas';
?>
<div class="space-y-6" id="schedule-builder-app">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<a href="<?= base_url('dashboard') ?>" class="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-primary">
<i class="bx bx-arrow-back text-xl"></i>
<span class="text-sm font-medium">Dashboard</span>
</a>
<h1 class="text-xl font-semibold">Schedule Builder — <?= esc($className) ?></h1>
</div>
<button type="button" id="btn-save" disabled class="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-hover focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="bx bx-save text-lg"></i>
<span>Simpan</span>
</button>
</div>
<div id="builder-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Loading…
</div>
<div id="builder-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="builder-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-4 py-3 font-medium border-b border-gray-200 dark:border-gray-700 w-40">Slot / Waktu</th>
<th class="px-4 py-3 font-medium border-b border-gray-200 dark:border-gray-700">Senin (1)</th>
<th class="px-4 py-3 font-medium border-b border-gray-200 dark:border-gray-700">Selasa (2)</th>
<th class="px-4 py-3 font-medium border-b border-gray-200 dark:border-gray-700">Rabu (3)</th>
<th class="px-4 py-3 font-medium border-b border-gray-200 dark:border-gray-700">Kamis (4)</th>
<th class="px-4 py-3 font-medium border-b border-gray-200 dark:border-gray-700">Jumat (5)</th>
</tr>
</thead>
<tbody id="builder-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
<!-- Success toast (TailAdmin-style) -->
<div id="builder-toast" role="alert" class="hidden fixed top-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/30 px-4 py-3 text-green-800 dark:text-green-200 shadow-lg transition-opacity duration-300">
<i class="bx bx-check-circle text-2xl text-green-600 dark:text-green-400"></i>
<span id="builder-toast-message">Saved.</span>
</div>
</div>
<script>
(function() {
var classId = <?= $classId ?>;
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiBase = baseUrl + '/api/academic';
var loading = document.getElementById('builder-loading');
var errorEl = document.getElementById('builder-error');
var content = document.getElementById('builder-content');
var tbody = document.getElementById('builder-tbody');
var btnSave = document.getElementById('btn-save');
var lessonSlots = [];
var subjects = [];
var teachers = [];
var subjectTeacherMap = {};
var scheduleGrid = [];
function showLoading() {
loading.classList.remove('hidden');
errorEl.classList.add('hidden');
content.classList.add('hidden');
btnSave.disabled = true;
}
function showError(msg) {
loading.classList.add('hidden');
content.classList.add('hidden');
errorEl.classList.remove('hidden');
errorEl.textContent = msg;
btnSave.disabled = true;
}
function showContent() {
loading.classList.add('hidden');
errorEl.classList.add('hidden');
content.classList.remove('hidden');
btnSave.disabled = false;
}
function fetchJson(url) {
return fetch(url, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); });
}
function escapeHtml(str) {
if (str == null) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showToast(message) {
var toast = document.getElementById('builder-toast');
var msgEl = document.getElementById('builder-toast-message');
if (!toast || !msgEl) return;
msgEl.textContent = message || 'Saved.';
toast.classList.remove('hidden');
clearTimeout(window._builderToastTimer);
window._builderToastTimer = setTimeout(function() {
toast.classList.add('opacity-0');
setTimeout(function() {
toast.classList.add('hidden');
toast.classList.remove('opacity-0');
}, 300);
}, 3000);
}
/** One dropdown per cell: options = "{subject_name} — {teacher_name}", value = subjectId_teacherId. Empty = no schedule. */
function renderCell(slotId, day, existing) {
var selectedValue = '';
if (existing && existing.subject_id) {
var tid = (existing.teacher_user_id != null && existing.teacher_user_id !== '') ? existing.teacher_user_id : '0';
selectedValue = existing.subject_id + '_' + tid;
}
var room = (existing && existing.room != null) ? escapeHtml(existing.room) : '';
var opts = '<option value="">— Tidak ada jadwal —</option>';
subjects.forEach(function(s) {
var labelNoGuru = escapeHtml(s.name) + ' — (Tidak ada guru)';
opts += '<option value="' + s.id + '_0"' + (selectedValue === s.id + '_0' ? ' selected' : '') + '>' + labelNoGuru + '</option>';
var allowedTeacherIds = subjectTeacherMap[s.id] || [];
if (!Array.isArray(allowedTeacherIds) || allowedTeacherIds.length === 0) {
return;
}
teachers.forEach(function(t) {
if (allowedTeacherIds.indexOf(t.id) === -1) return;
var val = s.id + '_' + t.id;
var label = escapeHtml(s.name) + ' — ' + escapeHtml(t.name);
var sel = selectedValue === val ? ' selected' : '';
opts += '<option value="' + val + '"' + sel + '>' + label + '</option>';
});
});
return '<div class="p-2 space-y-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800/50">' +
'<select class="cell-schedule w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm" data-slot="' + slotId + '" data-day="' + day + '">' + opts + '</select>' +
'<input type="text" class="cell-room w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm placeholder-gray-400" placeholder="Ruang (opsional)" value="' + room + '" data-slot="' + slotId + '" data-day="' + day + '">' +
'</div>';
}
function renderGrid() {
tbody.innerHTML = '';
lessonSlots.forEach(function(slot) {
var tr = document.createElement('tr');
tr.className = 'align-top';
var slotLabel = 'Slot ' + slot.slot_number + '<br><span class="text-xs text-gray-500">' + escapeHtml(slot.start_time) + ' ' + escapeHtml(slot.end_time) + '</span>';
var td0 = document.createElement('td');
td0.className = 'px-4 py-3 border-b border-gray-200 dark:border-gray-700 font-medium text-sm';
td0.innerHTML = slotLabel;
tr.appendChild(td0);
for (var day = 1; day <= 5; day++) {
var daySchedule = (slot.days && slot.days[day]) ? slot.days[day] : null;
var td = document.createElement('td');
td.className = 'px-4 py-3 border-b border-gray-200 dark:border-gray-700 min-w-[200px]';
td.innerHTML = renderCell(slot.id, day, daySchedule);
tr.appendChild(td);
}
tbody.appendChild(tr);
});
showContent();
}
function collectSchedules() {
var list = [];
lessonSlots.forEach(function(slot) {
for (var day = 1; day <= 5; day++) {
var sel = document.querySelector('.cell-schedule[data-slot="' + slot.id + '"][data-day="' + day + '"]');
var roomInp = document.querySelector('.cell-room[data-slot="' + slot.id + '"][data-day="' + day + '"]');
var val = sel ? (sel.value || '').trim() : '';
if (!val) continue;
var parts = val.split('_');
var subjectId = parseInt(parts[0], 10);
if (!subjectId) continue;
var teacherId = parseInt(parts[1], 10);
var room = roomInp && roomInp.value ? roomInp.value.trim() : null;
list.push({
day_of_week: day,
lesson_slot_id: slot.id,
subject_id: subjectId,
teacher_user_id: (teacherId === 0 || isNaN(teacherId)) ? undefined : teacherId,
room: room || undefined
});
}
});
return list;
}
function doSave() {
var payload = { class_id: classId, schedules: collectSchedules() };
btnSave.disabled = true;
fetch(apiBase + '/schedules/bulk-save', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, data: j }; }); })
.then(function(r) {
btnSave.disabled = false;
if (r.ok && r.data && r.data.success) {
showToast('Jadwal berhasil disimpan.');
} else {
var msg = (r.data && r.data.message) ? r.data.message : 'Gagal menyimpan';
showError(msg);
}
})
.catch(function() {
btnSave.disabled = false;
showError('Network error');
});
}
btnSave.addEventListener('click', doSave);
showLoading();
Promise.all([
fetchJson(apiBase + '/schedules/class/' + classId),
fetchJson(apiBase + '/lesson-slots'),
fetchJson(apiBase + '/subjects'),
fetchJson(baseUrl + '/api/users?role=GURU_MAPEL'),
fetchJson(apiBase + '/teacher-subjects/map')
]).then(function(results) {
var sched = results[0];
var slots = results[1];
var subj = results[2];
var users = results[3];
var mapRes = results[4];
if (!sched.ok || !sched.data || !sched.data.success) {
showError(sched.data && sched.data.message ? sched.data.message : 'Gagal memuat jadwal');
return;
}
if (!slots.ok || !slots.data || !slots.data.success) {
showError(slots.data && slots.data.message ? slots.data.message : 'Gagal memuat slot');
return;
}
if (!subj.ok || !subj.data || !subj.data.success) {
showError(subj.data && subj.data.message ? subj.data.message : 'Gagal memuat mapel');
return;
}
if (!users.ok || !users.data || !users.data.success) {
showError(users.data && users.data.message ? users.data.message : 'Gagal memuat guru');
return;
}
if (!mapRes.ok || !mapRes.data || !mapRes.data.success) {
showError(mapRes.data && mapRes.data.message ? mapRes.data.message : 'Gagal memuat relasi guru-mapel');
return;
}
scheduleGrid = (sched.data.data != null) ? sched.data.data : [];
lessonSlots = (slots.data.data != null) ? slots.data.data : [];
lessonSlots.sort(function(a, b) { return (a.slot_number || 0) - (b.slot_number || 0); });
subjects = (subj.data.data != null) ? subj.data.data : [];
teachers = (users.data.data != null) ? users.data.data : [];
subjectTeacherMap = (mapRes.data.data != null) ? mapRes.data.data : {};
lessonSlots.forEach(function(slot) {
var row = scheduleGrid.find(function(r) { return r.lesson_slot_id === slot.id; });
slot.days = (row && row.days) ? row.days : {};
});
renderGrid();
}).catch(function() {
showError('Network error');
});
})();
</script>

View File

@@ -0,0 +1,99 @@
<div class="space-y-6">
<h1 class="text-xl font-semibold">Schedule Builder</h1>
<p class="text-gray-600 dark:text-gray-400">Pilih kelas lalu buka builder untuk mengatur jadwal mingguan.</p>
<div id="index-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Loading kelas…
</div>
<div id="index-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="index-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="p-6">
<label for="class-select" class="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Kelas</label>
<select id="class-select" class="w-full max-w-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm focus:ring-2 focus:ring-primary">
<option value=""> Pilih kelas </option>
</select>
<div class="mt-4">
<a id="btn-open-builder" href="#" class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-white hover:opacity-90 focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:pointer-events-none">
<i class="bx bx-grid-alt text-lg"></i>
<span>Open Builder</span>
</a>
</div>
</div>
</div>
</div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiUrl = baseUrl + '/api/academic/classes';
var builderBaseUrl = baseUrl + '/dashboard/academic/schedule-builder/';
var loading = document.getElementById('index-loading');
var errorEl = document.getElementById('index-error');
var content = document.getElementById('index-content');
var select = document.getElementById('class-select');
var btnOpen = document.getElementById('btn-open-builder');
function showLoading() {
loading.classList.remove('hidden');
errorEl.classList.add('hidden');
content.classList.add('hidden');
}
function showError(msg) {
loading.classList.add('hidden');
content.classList.add('hidden');
errorEl.classList.remove('hidden');
errorEl.textContent = msg;
}
function showContent() {
loading.classList.add('hidden');
errorEl.classList.add('hidden');
content.classList.remove('hidden');
}
function updateButton() {
var val = select.value;
if (val) {
btnOpen.href = builderBaseUrl + val;
btnOpen.classList.remove('disabled', 'opacity-50', 'pointer-events-none');
} else {
btnOpen.href = '#';
btnOpen.classList.add('disabled', 'opacity-50', 'pointer-events-none');
}
}
fetch(apiUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
showError(r.data && r.data.message ? r.data.message : 'Gagal memuat daftar kelas');
return;
}
var list = r.data && r.data.data ? r.data.data : r.data;
if (!Array.isArray(list)) {
showError('Invalid response');
return;
}
list.forEach(function(c) {
var opt = document.createElement('option');
var label = c.full_label;
if (!label) {
var parts = [];
if (c.grade) parts.push(c.grade);
if (c.major) parts.push(c.major);
if (c.name) parts.push(c.name);
label = parts.join(' ').trim() || ('Kelas ' + c.id);
}
opt.value = c.id;
opt.textContent = label;
select.appendChild(opt);
});
showContent();
select.addEventListener('change', updateButton);
updateButton();
})
.catch(function() {
showError('Network error');
});
})();
</script>

View File

@@ -0,0 +1,279 @@
<div class="space-y-6">
<h1 class="text-xl font-semibold">Jadwal Hari Ini</h1>
<!-- Banner: saat ini guru harus di kelas mana -->
<div id="current-schedule-banner" class="hidden rounded-2xl border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 p-4">
<p class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-1">📍 Anda seharusnya di:</p>
<p id="current-schedule-text" class="text-base font-semibold text-blue-900 dark:text-blue-100"></p>
<p id="current-schedule-hint" class="text-xs text-blue-600 dark:text-blue-300 mt-1"></p>
</div>
<div id="current-schedule-empty" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-4 text-gray-600 dark:text-gray-400 text-sm">
Tidak ada jadwal mengajar Anda hari ini, atau tidak ada jam aktif saat ini.
</div>
<div id="schedule-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Loading schedules…
</div>
<div id="schedule-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="schedule-empty" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">
Tidak ada jadwal untuk hari ini.
</div>
<div id="schedule-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">Mapel</th>
<th class="px-6 py-3 font-medium">Kelas</th>
<th class="px-6 py-3 font-medium">Guru</th>
<th class="px-6 py-3 font-medium">Jam</th>
<th class="px-6 py-3 font-medium w-52">Aksi</th>
</tr>
</thead>
<tbody id="schedule-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
<!-- Modal QR Absen Mapel -->
<div id="qr-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" id="qr-modal-backdrop"></div>
<div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">QR Absen Mapel</h2>
<button type="button" id="qr-modal-close" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400">&times;</button>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1"><strong id="qr-subject">-</strong> <span id="qr-class">-</span></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4" id="qr-expires"></p>
<div id="qr-code-container" class="flex justify-center my-4"></div>
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">Siswa scan QR ini dengan aplikasi presensi untuk absen mapel.</p>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var reportBaseUrl = baseUrl + '/dashboard/attendance/report/';
var apiUrl = baseUrl + '/api/dashboard/schedules/today';
var currentApiUrl = baseUrl + '/api/dashboard/schedules/current';
var currentBanner = document.getElementById('current-schedule-banner');
var currentText = document.getElementById('current-schedule-text');
var currentHint = document.getElementById('current-schedule-hint');
var currentEmpty = document.getElementById('current-schedule-empty');
var loading = document.getElementById('schedule-loading');
var errorEl = document.getElementById('schedule-error');
var emptyEl = document.getElementById('schedule-empty');
var content = document.getElementById('schedule-content');
var tbody = document.getElementById('schedule-tbody');
function fetchCurrentAndHighlight() {
fetch(currentApiUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok || !r.data) {
if (currentEmpty) currentEmpty.classList.remove('hidden');
return;
}
var d = r.data.data || r.data;
var scheduleIdToHighlight = null;
var isActiveNow = d.is_active_now === true;
if (isActiveNow && d.schedule_id) {
scheduleIdToHighlight = d.schedule_id;
if (currentBanner) {
currentBanner.classList.remove('hidden');
currentBanner.classList.remove('border-amber-200', 'dark:border-amber-800', 'bg-amber-50', 'dark:bg-amber-900/20');
currentBanner.classList.add('border-blue-200', 'dark:border-blue-800', 'bg-blue-50', 'dark:bg-blue-900/20');
}
if (currentText) currentText.textContent = (d.subject_name || '-') + ' — Kelas ' + (d.class_name || '-');
if (currentHint) currentHint.textContent = 'Jam ' + (d.start_time || '') + ' ' + (d.end_time || '') + '. Tampilkan QR untuk jadwal ini di bawah.';
if (currentEmpty) currentEmpty.classList.add('hidden');
} else if (d.next_schedule && d.next_schedule.schedule_id) {
var next = d.next_schedule;
scheduleIdToHighlight = next.schedule_id;
if (currentBanner) {
currentBanner.classList.remove('hidden');
currentBanner.classList.remove('border-blue-200', 'dark:border-blue-800', 'bg-blue-50', 'dark:bg-blue-900/20');
currentBanner.classList.add('border-amber-200', 'dark:border-amber-800', 'bg-amber-50', 'dark:bg-amber-900/20');
}
if (currentText) currentText.textContent = 'Berikutnya: ' + (next.subject_name || '-') + ' — Kelas ' + (next.class_name || '-') + ' (mulai ' + (next.start_time || '') + ')';
if (currentHint) currentHint.textContent = 'Belum jam mengajar. Tampilkan QR saat jam tersebut tiba.';
if (currentEmpty) currentEmpty.classList.add('hidden');
} else {
if (currentBanner) currentBanner.classList.add('hidden');
if (currentEmpty) currentEmpty.classList.remove('hidden');
}
if (scheduleIdToHighlight && tbody) {
var rows = tbody.querySelectorAll('tr[data-schedule-id]');
rows.forEach(function(tr) {
var id = tr.getAttribute('data-schedule-id');
if (id && parseInt(id, 10) === scheduleIdToHighlight) {
tr.classList.add('bg-blue-50', 'dark:bg-blue-900/20', 'ring-2', 'ring-inset', 'ring-blue-400', 'dark:ring-blue-500');
var firstCell = tr.querySelector('td');
if (firstCell && !firstCell.querySelector('.badge-current')) {
var badge = document.createElement('span');
badge.className = 'badge-current inline-block ml-1 px-2 py-0.5 text-xs font-medium rounded-full ' + (isActiveNow ? 'bg-blue-600 text-white' : 'bg-amber-500 text-white');
badge.textContent = isActiveNow ? 'Saat ini' : 'Berikutnya';
firstCell.appendChild(badge);
}
}
});
}
})
.catch(function() {
if (currentEmpty) currentEmpty.classList.remove('hidden');
});
}
function showLoading() {
var errorEl = document.getElementById('schedule-error');
var emptyEl = document.getElementById('schedule-empty');
var content = document.getElementById('schedule-content');
var tbody = document.getElementById('schedule-tbody');
function showLoading() {
loading.classList.remove('hidden');
errorEl.classList.add('hidden');
emptyEl.classList.add('hidden');
content.classList.add('hidden');
}
function showError(msg) {
loading.classList.add('hidden');
emptyEl.classList.add('hidden');
content.classList.add('hidden');
errorEl.classList.remove('hidden');
errorEl.textContent = msg;
}
function showEmpty() {
loading.classList.add('hidden');
errorEl.classList.add('hidden');
content.classList.add('hidden');
emptyEl.classList.remove('hidden');
}
function showContent() {
loading.classList.add('hidden');
errorEl.classList.add('hidden');
emptyEl.classList.add('hidden');
content.classList.remove('hidden');
}
function escapeHtml(str) {
if (str == null) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
fetch(apiUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
showError(r.data && r.data.message ? r.data.message : 'Gagal memuat jadwal');
return;
}
var list = r.data && r.data.data ? r.data.data : r.data;
if (!Array.isArray(list)) {
showError('Data jadwal tidak valid');
return;
}
if (list.length === 0) {
showEmpty();
return;
}
tbody.innerHTML = '';
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
tr.setAttribute('data-schedule-id', row.schedule_id);
var timeStr = (row.start_time || '') + ' ' + (row.end_time || '');
var reportUrl = reportBaseUrl + row.schedule_id;
tr.innerHTML =
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.subject_name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.class_name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.teacher_name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(timeStr) + '</td>' +
'<td class="px-6 py-3 flex flex-wrap gap-2">' +
'<a href="' + escapeHtml(reportUrl) + '" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-primary rounded-lg hover:opacity-90">Laporan</a>' +
'<button type="button" class="btn-qr inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-600 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-500" data-schedule-id="' + row.schedule_id + '" data-subject="' + escapeHtml(row.subject_name) + '" data-class="' + escapeHtml(row.class_name) + '">Tampilkan QR</button>' +
'</td>';
tbody.appendChild(tr);
});
showContent();
fetchCurrentAndHighlight();
})
.catch(function() {
showError('Koneksi gagal');
});
var qrModal = document.getElementById('qr-modal');
var qrContainer = document.getElementById('qr-code-container');
var qrSubject = document.getElementById('qr-subject');
var qrClass = document.getElementById('qr-class');
var qrExpires = document.getElementById('qr-expires');
var qrCloseBtn = document.getElementById('qr-modal-close');
var qrApiUrl = baseUrl + '/api/dashboard/qr-attendance/generate';
function closeQrModal() {
if (qrModal) qrModal.classList.add('hidden');
if (qrContainer) {
qrContainer.innerHTML = '';
}
}
tbody.addEventListener('click', function(e) {
var btn = e.target.closest('.btn-qr');
if (!btn) return;
var scheduleId = btn.getAttribute('data-schedule-id');
var subject = btn.getAttribute('data-subject') || '-';
var classNm = btn.getAttribute('data-class') || '-';
if (!scheduleId) return;
if (qrSubject) qrSubject.textContent = subject;
if (qrClass) qrClass.textContent = classNm;
if (qrExpires) qrExpires.textContent = 'Memuat…';
if (qrContainer) qrContainer.innerHTML = '<p class="text-gray-500">Memuat…</p>';
fetch(qrApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
body: JSON.stringify({ schedule_id: parseInt(scheduleId, 10) })
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (!res.success || !res.data || !res.data.token) {
if (qrContainer) qrContainer.innerHTML = '<p class="text-red-500">' + (res.message || 'Gagal generate QR') + '</p>';
if (qrExpires) qrExpires.textContent = '';
return;
}
var token = res.data.token;
var expiresAt = res.data.expires_at || '';
if (qrExpires) qrExpires.textContent = 'Berlaku sampai: ' + expiresAt;
qrContainer.innerHTML = '';
var qrDiv = document.createElement('div');
qrDiv.id = 'qrcode';
qrDiv.className = 'inline-block p-2 bg-white rounded-lg';
qrContainer.appendChild(qrDiv);
if (typeof QRCode !== 'undefined') {
new QRCode(qrDiv, { text: token, width: 220, height: 220 });
} else {
qrDiv.innerHTML = '<p class="text-gray-600">Token: ' + token.substring(0, 16) + '…<br><small>Pasang library QRCode untuk tampil QR.</small></p>';
}
if (qrModal) qrModal.classList.remove('hidden');
})
.catch(function() {
if (qrContainer) qrContainer.innerHTML = '<p class="text-red-500">Gagal memuat (jaringan).</p>';
if (qrExpires) qrExpires.textContent = '';
});
});
if (qrCloseBtn) qrCloseBtn.addEventListener('click', closeQrModal);
})();
</script>

View File

@@ -0,0 +1,379 @@
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold">Siswa</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Kelola data siswa.</p>
</div>
<button type="button" id="btn-add-student" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors">
<i class="bx bx-plus text-xl"></i>
<span>Tambah Siswa</span>
</button>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="filter-search" class="sr-only">Cari</label>
<input type="text" id="filter-search" placeholder="Cari nama atau NISN..." class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div class="w-full sm:w-48">
<label for="filter-class" class="sr-only">Kelas</label>
<select id="filter-class" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="">Semua kelas</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer self-center whitespace-nowrap">
<input type="checkbox" id="filter-unmapped" class="rounded border-gray-300 text-primary focus:ring-primary">
<span class="text-sm text-gray-700 dark:text-gray-300">Hanya unmapped</span>
</label>
<div class="flex items-center gap-2 self-center">
<label for="filter-per-page" class="text-sm text-gray-600 dark:text-gray-400">Per halaman</label>
<select id="filter-per-page" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm">
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div id="students-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">Memuat…</div>
<div id="students-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="students-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">NISN</th>
<th class="px-6 py-3 font-medium">Nama</th>
<th class="px-6 py-3 font-medium">JK</th>
<th class="px-6 py-3 font-medium">Kelas</th>
<th class="px-6 py-3 font-medium">Status</th>
<th class="px-6 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody id="students-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
<div id="students-pagination" class="flex flex-wrap items-center justify-between gap-4 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<p id="pagination-info" class="text-sm text-gray-600 dark:text-gray-400"></p>
<div id="pagination-buttons" class="flex items-center gap-2"></div>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="modal-student" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-student-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div class="p-6">
<h2 id="modal-student-title" class="text-lg font-semibold mb-4">Tambah Siswa</h2>
<form id="form-student">
<input type="hidden" id="form-student-id" value="">
<div class="space-y-4">
<div>
<label for="form-student-nisn" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">NISN <span class="text-red-500">*</span></label>
<input type="text" id="form-student-nisn" name="nisn" required maxlength="50" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div>
<label for="form-student-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nama <span class="text-red-500">*</span></label>
<input type="text" id="form-student-name" name="name" required maxlength="255" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div>
<label for="form-student-gender" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Jenis Kelamin</label>
<select id="form-student-gender" name="gender" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
<option value=""> Pilih </option>
<option value="L">Laki-laki</option>
<option value="P">Perempuan</option>
</select>
</div>
<div>
<label for="form-student-class" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kelas</label>
<select id="form-student-class" name="class_id" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
<option value=""> Tidak ada </option>
</select>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="form-student-active" name="is_active" value="1" checked class="rounded border-gray-300 text-primary focus:ring-primary">
<span class="text-sm text-gray-700 dark:text-gray-300">Aktif</span>
</label>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" id="form-student-submit" class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover">Simpan</button>
<button type="button" id="form-student-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Delete confirmation -->
<div id="modal-student-delete" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-student-delete-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-sm rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold mb-2">Hapus Siswa</h2>
<p id="modal-student-delete-message" class="text-gray-600 dark:text-gray-400 mb-6">Yakin ingin menghapus?</p>
<div class="flex gap-3">
<button type="button" id="modal-student-delete-confirm" class="flex-1 px-4 py-2.5 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700">Hapus</button>
<button type="button" id="modal-student-delete-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</div>
</div>
</div>
<div id="toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiStudents = baseUrl + '/api/academic/students';
var apiClasses = baseUrl + '/api/academic/classes';
var loading = document.getElementById('students-loading');
var errorEl = document.getElementById('students-error');
var content = document.getElementById('students-content');
var tbody = document.getElementById('students-tbody');
var filterSearch = document.getElementById('filter-search');
var filterClass = document.getElementById('filter-class');
var filterUnmapped = document.getElementById('filter-unmapped');
var filterPerPage = document.getElementById('filter-per-page');
var btnAdd = document.getElementById('btn-add-student');
var paginationInfo = document.getElementById('pagination-info');
var paginationButtons = document.getElementById('pagination-buttons');
var currentPage = 1;
var modal = document.getElementById('modal-student');
var modalTitle = document.getElementById('modal-student-title');
var form = document.getElementById('form-student');
var formId = document.getElementById('form-student-id');
var formNisn = document.getElementById('form-student-nisn');
var formName = document.getElementById('form-student-name');
var formGender = document.getElementById('form-student-gender');
var formClass = document.getElementById('form-student-class');
var formActive = document.getElementById('form-student-active');
var formSubmit = document.getElementById('form-student-submit');
var formCancel = document.getElementById('form-student-cancel');
var modalBackdrop = document.getElementById('modal-student-backdrop');
var modalDelete = document.getElementById('modal-student-delete');
var modalDeleteMsg = document.getElementById('modal-student-delete-message');
var modalDeleteConfirm = document.getElementById('modal-student-delete-confirm');
var modalDeleteCancel = document.getElementById('modal-student-delete-cancel');
var modalDeleteBackdrop = document.getElementById('modal-student-delete-backdrop');
var toastContainer = document.getElementById('toast-container');
var deleteTargetId = null;
var classesList = [];
var searchTimeout = null;
function showLoading() { loading.classList.remove('hidden'); errorEl.classList.add('hidden'); content.classList.add('hidden'); }
function showError(msg) { loading.classList.add('hidden'); content.classList.add('hidden'); errorEl.classList.remove('hidden'); errorEl.textContent = msg; }
function showContent() { loading.classList.add('hidden'); errorEl.classList.add('hidden'); content.classList.remove('hidden'); }
function escapeHtml(str) { if (str == null) return ''; var d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
}
var fetchOpts = { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } };
function postOpts(body) { return { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function putOpts(body) { return { method: 'PUT', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function deleteOpts() { return { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } }; }
function loadClasses(callback) {
fetch(apiClasses, fetchOpts)
.then(function(res) { return res.json(); })
.then(function(r) {
var list = (r && r.data && r.data.data) ? r.data.data : (Array.isArray(r && r.data) ? r.data : []);
classesList = list;
filterClass.innerHTML = '<option value="">Semua kelas</option>';
formClass.innerHTML = '<option value="">— Tidak ada —</option>';
list.forEach(function(c) {
var label = c.full_label || (c.grade + ' ' + (c.major || '') + ' ' + c.name).trim();
var opt1 = document.createElement('option');
opt1.value = c.id;
opt1.textContent = label;
filterClass.appendChild(opt1);
var opt2 = document.createElement('option');
opt2.value = c.id;
opt2.textContent = label;
formClass.appendChild(opt2);
});
if (callback) callback();
})
.catch(function() { if (callback) callback(); });
}
function buildStudentsUrl(page) {
var p = page != null ? page : currentPage;
var url = apiStudents;
var params = [];
if (filterUnmapped && filterUnmapped.checked) params.push('unmapped_only=1');
else if (filterClass.value) params.push('class_id=' + encodeURIComponent(filterClass.value));
if (filterSearch.value.trim()) params.push('search=' + encodeURIComponent(filterSearch.value.trim()));
params.push('page=' + p);
params.push('per_page=' + (filterPerPage ? filterPerPage.value : 20));
url += '?' + params.join('&');
return url;
}
function renderPagination(meta) {
if (!paginationInfo || !paginationButtons || !meta) return;
var total = meta.total || 0;
var page = meta.page || 1;
var perPage = meta.per_page || 20;
var totalPages = meta.total_pages || 0;
var from = total === 0 ? 0 : (page - 1) * perPage + 1;
var to = Math.min(page * perPage, total);
paginationInfo.textContent = 'Menampilkan ' + from + '' + to + ' dari ' + total + ' siswa';
paginationButtons.innerHTML = '';
if (totalPages <= 1) return;
var prevBtn = document.createElement('button');
prevBtn.type = 'button';
prevBtn.className = 'px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed';
prevBtn.textContent = 'Sebelumnya';
prevBtn.disabled = page <= 1;
prevBtn.addEventListener('click', function() { if (page > 1) { currentPage = page - 1; loadStudents(); } });
paginationButtons.appendChild(prevBtn);
var nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed';
nextBtn.textContent = 'Berikutnya';
nextBtn.disabled = page >= totalPages;
nextBtn.addEventListener('click', function() { if (page < totalPages) { currentPage = page + 1; loadStudents(); } });
paginationButtons.appendChild(nextBtn);
var pageInfo = document.createElement('span');
pageInfo.className = 'text-sm text-gray-600 dark:text-gray-400 ml-2';
pageInfo.textContent = 'Halaman ' + page + ' dari ' + totalPages;
paginationButtons.appendChild(pageInfo);
}
function loadStudents(page) {
if (page != null) currentPage = page;
showLoading();
fetch(buildStudentsUrl(), fetchOpts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) { showError(r.data && r.data.message ? r.data.message : 'Gagal memuat'); return; }
var response = r.data;
var list = (response && response.data) ? response.data : (Array.isArray(response) ? response : []);
var meta = (response && response.meta) ? response.meta : null;
tbody.innerHTML = '';
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
var genderText = row.gender === 'L' ? 'L' : row.gender === 'P' ? 'P' : '';
var classCell = row.class_id == null
? '<span class="inline-flex px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">UNMAPPED</span>'
: (row.class_label ? escapeHtml(row.class_label) : '');
var status = row.is_active ? 'Aktif' : 'Nonaktif';
var statusClass = row.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
tr.innerHTML =
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.nisn) + '</td>' +
'<td class="px-6 py-3">' + escapeHtml(row.name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + genderText + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + classCell + '</td>' +
'<td class="px-6 py-3"><span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium ' + statusClass + '">' + status + '</span></td>' +
'<td class="px-6 py-3 text-right">' +
'<button type="button" class="btn-edit px-3 py-1.5 rounded-lg text-primary hover:bg-primary/10" data-id="' + row.id + '" data-nisn="' + escapeHtml(row.nisn) + '" data-name="' + escapeHtml(row.name) + '" data-gender="' + (row.gender || '') + '" data-class-id="' + (row.class_id || '') + '" data-active="' + (row.is_active ? '1' : '0') + '">Edit</button> ' +
'<button type="button" class="btn-delete px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20" data-id="' + row.id + '" data-name="' + escapeHtml(row.name) + '">Hapus</button>' +
'</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
formId.value = btn.getAttribute('data-id');
formNisn.value = btn.getAttribute('data-nisn') || '';
formName.value = btn.getAttribute('data-name') || '';
formGender.value = btn.getAttribute('data-gender') || '';
formClass.value = btn.getAttribute('data-class-id') || '';
formActive.checked = btn.getAttribute('data-active') === '1';
modalTitle.textContent = 'Edit Siswa';
modal.classList.remove('hidden');
});
});
tbody.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteTargetId = parseInt(btn.getAttribute('data-id'), 10);
modalDeleteMsg.textContent = 'Yakin ingin menghapus "' + (btn.getAttribute('data-name') || '') + '"?';
modalDelete.classList.remove('hidden');
});
});
renderPagination(meta);
showContent();
})
.catch(function() { showError('Gagal memuat data'); });
}
filterClass.addEventListener('change', function() { currentPage = 1; loadStudents(); });
if (filterUnmapped) filterUnmapped.addEventListener('change', function() { currentPage = 1; loadStudents(); });
if (filterPerPage) filterPerPage.addEventListener('change', function() { currentPage = 1; loadStudents(); });
filterSearch.addEventListener('input', function() {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() { currentPage = 1; loadStudents(); }, 300);
});
btnAdd.addEventListener('click', function() {
formId.value = ''; formNisn.value = ''; formName.value = ''; formGender.value = ''; formClass.value = '';
formActive.checked = true;
modalTitle.textContent = 'Tambah Siswa';
modal.classList.remove('hidden');
});
form.addEventListener('submit', function(e) {
e.preventDefault();
var id = formId.value ? parseInt(formId.value, 10) : null;
var payload = {
nisn: formNisn.value.trim(),
name: formName.value.trim(),
gender: formGender.value || null,
class_id: formClass.value ? parseInt(formClass.value, 10) : null,
is_active: formActive.checked ? 1 : 0
};
formSubmit.disabled = true;
var url = apiStudents;
var opts = postOpts(payload);
if (id) { url = apiStudents + '/' + id; opts = putOpts(payload); }
fetch(url, opts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
formSubmit.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan', 'error'); return; }
showToast(id ? 'Berhasil diubah' : 'Berhasil ditambahkan');
modal.classList.add('hidden');
loadStudents();
})
.catch(function() { formSubmit.disabled = false; showToast('Gagal menyimpan', 'error'); });
});
formCancel.addEventListener('click', function() { modal.classList.add('hidden'); });
modalBackdrop.addEventListener('click', function() { modal.classList.add('hidden'); });
modalDeleteConfirm.addEventListener('click', function() {
if (!deleteTargetId) return;
var id = deleteTargetId;
modalDeleteConfirm.disabled = true;
fetch(apiStudents + '/' + id, deleteOpts())
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
modalDeleteConfirm.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menghapus', 'error'); return; }
showToast('Berhasil dihapus');
modalDelete.classList.add('hidden');
deleteTargetId = null;
loadStudents();
})
.catch(function() { modalDeleteConfirm.disabled = false; showToast('Gagal menghapus', 'error'); });
});
modalDeleteCancel.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
modalDeleteBackdrop.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
loadClasses(function() { loadStudents(); });
})();
</script>

View File

@@ -0,0 +1,215 @@
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold">Mata Pelajaran</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Kelola data mata pelajaran.</p>
</div>
<button type="button" id="btn-add-subject" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors">
<i class="bx bx-plus text-xl"></i>
<span>Tambah Mata Pelajaran</span>
</button>
</div>
<div id="subjects-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">Memuat…</div>
<div id="subjects-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="subjects-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">Nama</th>
<th class="px-6 py-3 font-medium">Kode</th>
<th class="px-6 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody id="subjects-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="modal-subject" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-subject-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div class="p-6">
<h2 id="modal-subject-title" class="text-lg font-semibold mb-4">Tambah Mata Pelajaran</h2>
<form id="form-subject">
<input type="hidden" id="form-subject-id" value="">
<div class="space-y-4">
<div>
<label for="form-subject-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nama <span class="text-red-500">*</span></label>
<input type="text" id="form-subject-name" name="name" required maxlength="255" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Contoh: Matematika">
</div>
<div>
<label for="form-subject-code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kode</label>
<input type="text" id="form-subject-code" name="code" maxlength="50" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Contoh: MAT">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" id="form-subject-submit" class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover">Simpan</button>
<button type="button" id="form-subject-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Delete confirmation -->
<div id="modal-subject-delete" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-subject-delete-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-sm rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold mb-2">Hapus Mata Pelajaran</h2>
<p id="modal-subject-delete-message" class="text-gray-600 dark:text-gray-400 mb-6">Yakin ingin menghapus?</p>
<div class="flex gap-3">
<button type="button" id="modal-subject-delete-confirm" class="flex-1 px-4 py-2.5 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700">Hapus</button>
<button type="button" id="modal-subject-delete-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</div>
</div>
</div>
<div id="toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiSubjects = baseUrl + '/api/academic/subjects';
var loading = document.getElementById('subjects-loading');
var errorEl = document.getElementById('subjects-error');
var content = document.getElementById('subjects-content');
var tbody = document.getElementById('subjects-tbody');
var btnAdd = document.getElementById('btn-add-subject');
var modal = document.getElementById('modal-subject');
var modalTitle = document.getElementById('modal-subject-title');
var form = document.getElementById('form-subject');
var formId = document.getElementById('form-subject-id');
var formName = document.getElementById('form-subject-name');
var formCode = document.getElementById('form-subject-code');
var formSubmit = document.getElementById('form-subject-submit');
var formCancel = document.getElementById('form-subject-cancel');
var modalBackdrop = document.getElementById('modal-subject-backdrop');
var modalDelete = document.getElementById('modal-subject-delete');
var modalDeleteMsg = document.getElementById('modal-subject-delete-message');
var modalDeleteConfirm = document.getElementById('modal-subject-delete-confirm');
var modalDeleteCancel = document.getElementById('modal-subject-delete-cancel');
var modalDeleteBackdrop = document.getElementById('modal-subject-delete-backdrop');
var toastContainer = document.getElementById('toast-container');
var deleteTargetId = null;
function showLoading() { loading.classList.remove('hidden'); errorEl.classList.add('hidden'); content.classList.add('hidden'); }
function showError(msg) { loading.classList.add('hidden'); content.classList.add('hidden'); errorEl.classList.remove('hidden'); errorEl.textContent = msg; }
function showContent() { loading.classList.add('hidden'); errorEl.classList.add('hidden'); content.classList.remove('hidden'); }
function escapeHtml(str) { if (str == null) return ''; var d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
}
var fetchOpts = { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } };
function postOpts(body) { return { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function putOpts(body) { return { method: 'PUT', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function deleteOpts() { return { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } }; }
function loadSubjects() {
showLoading();
fetch(apiSubjects, fetchOpts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) { showError(r.data && r.data.message ? r.data.message : 'Gagal memuat'); return; }
var list = (r.data && r.data.data) ? r.data.data : (Array.isArray(r.data) ? r.data : []);
tbody.innerHTML = '';
list.forEach(function(row) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
tr.innerHTML =
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + (row.code ? escapeHtml(row.code) : '') + '</td>' +
'<td class="px-6 py-3 text-right">' +
'<button type="button" class="btn-edit px-3 py-1.5 rounded-lg text-primary hover:bg-primary/10" data-id="' + row.id + '" data-name="' + escapeHtml(row.name) + '" data-code="' + escapeHtml(row.code || '') + '">Edit</button> ' +
'<button type="button" class="btn-delete px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20" data-id="' + row.id + '" data-name="' + escapeHtml(row.name) + '">Hapus</button>' +
'</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
formId.value = btn.getAttribute('data-id');
formName.value = btn.getAttribute('data-name') || '';
formCode.value = btn.getAttribute('data-code') || '';
modalTitle.textContent = 'Edit Mata Pelajaran';
modal.classList.remove('hidden');
});
});
tbody.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteTargetId = parseInt(btn.getAttribute('data-id'), 10);
modalDeleteMsg.textContent = 'Yakin ingin menghapus "' + (btn.getAttribute('data-name') || '') + '"?';
modalDelete.classList.remove('hidden');
});
});
showContent();
})
.catch(function() { showError('Gagal memuat data'); });
}
btnAdd.addEventListener('click', function() {
formId.value = ''; formName.value = ''; formCode.value = '';
modalTitle.textContent = 'Tambah Mata Pelajaran';
modal.classList.remove('hidden');
});
form.addEventListener('submit', function(e) {
e.preventDefault();
var id = formId.value ? parseInt(formId.value, 10) : null;
var payload = { name: formName.value.trim(), code: formCode.value.trim() || null };
formSubmit.disabled = true;
var url = apiSubjects;
var opts = postOpts(payload);
if (id) { url = apiSubjects + '/' + id; opts = putOpts(payload); }
fetch(url, opts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
formSubmit.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan', 'error'); return; }
showToast(id ? 'Berhasil diubah' : 'Berhasil ditambahkan');
modal.classList.add('hidden');
loadSubjects();
})
.catch(function() { formSubmit.disabled = false; showToast('Gagal menyimpan', 'error'); });
});
formCancel.addEventListener('click', function() { modal.classList.add('hidden'); });
modalBackdrop.addEventListener('click', function() { modal.classList.add('hidden'); });
modalDeleteConfirm.addEventListener('click', function() {
if (!deleteTargetId) return;
var id = deleteTargetId;
modalDeleteConfirm.disabled = true;
fetch(apiSubjects + '/' + id, deleteOpts())
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
modalDeleteConfirm.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menghapus', 'error'); return; }
showToast('Berhasil dihapus');
modalDelete.classList.add('hidden');
deleteTargetId = null;
loadSubjects();
})
.catch(function() { modalDeleteConfirm.disabled = false; showToast('Gagal menghapus', 'error'); });
});
modalDeleteCancel.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
modalDeleteBackdrop.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
loadSubjects();
})();
</script>

View File

@@ -0,0 +1,393 @@
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold">Guru / PTK</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Kelola data guru dan wali kelas.</p>
</div>
<button type="button" id="btn-add-teacher" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors">
<i class="bx bx-plus text-xl"></i>
<span>Tambah Guru</span>
</button>
</div>
<div id="teachers-loading" class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center text-gray-500 dark:text-gray-400">Memuat…</div>
<div id="teachers-error" class="hidden rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-6 text-red-700 dark:text-red-300"></div>
<div id="teachers-content" class="hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
<tr>
<th class="px-6 py-3 font-medium">Nama</th>
<th class="px-6 py-3 font-medium">Email</th>
<th class="px-6 py-3 font-medium">Role</th>
<th class="px-6 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody id="teachers-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="modal-teacher" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-teacher-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div class="p-6">
<h2 id="modal-teacher-title" class="text-lg font-semibold mb-4">Tambah Guru</h2>
<form id="form-teacher">
<input type="hidden" id="form-teacher-id" value="">
<div class="space-y-4">
<div>
<label for="form-teacher-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nama <span class="text-red-500">*</span></label>
<input type="text" id="form-teacher-name" name="name" required maxlength="255" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div>
<label for="form-teacher-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email <span class="text-red-500">*</span></label>
<input type="email" id="form-teacher-email" name="email" required maxlength="255" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div id="form-teacher-password-wrap">
<label for="form-teacher-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password <span id="form-teacher-password-req" class="text-red-500">*</span></label>
<input type="password" id="form-teacher-password" name="password" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Kosongkan jika tidak diubah">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role <span class="text-red-500">*</span></label>
<div class="space-y-2">
<label class="inline-flex items-center gap-2">
<input type="checkbox" id="form-role-guru" class="rounded border-gray-300 text-primary focus:ring-primary">
<span class="text-sm text-gray-700 dark:text-gray-300">Guru Mata Pelajaran</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" id="form-role-wali" class="rounded border-gray-300 text-primary focus:ring-primary">
<span class="text-sm text-gray-700 dark:text-gray-300">Wali Kelas</span>
</label>
</div>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" id="form-teacher-submit" class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover">Simpan</button>
<button type="button" id="form-teacher-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Mapel Diajar Modal -->
<div id="modal-subjects" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-subjects-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div class="p-6">
<h2 id="modal-subjects-title" class="text-lg font-semibold mb-4">Mapel yang Diajar</h2>
<p id="modal-subjects-teacher" class="text-sm text-gray-600 dark:text-gray-400 mb-4"></p>
<div id="modal-subjects-body" class="max-h-72 overflow-y-auto space-y-2 text-sm text-gray-700 dark:text-gray-200">
<!-- checkbox subjects will be injected here -->
</div>
<div class="flex gap-3 mt-6">
<button type="button" id="modal-subjects-save" class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover">Simpan</button>
<button type="button" id="modal-subjects-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete confirmation -->
<div id="modal-teacher-delete" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="fixed inset-0 bg-black/50" id="modal-teacher-delete-backdrop"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="relative w-full max-w-sm rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold mb-2">Hapus Guru</h2>
<p id="modal-teacher-delete-message" class="text-gray-600 dark:text-gray-400 mb-6">Yakin ingin menghapus?</p>
<div class="flex gap-3">
<button type="button" id="modal-teacher-delete-confirm" class="flex-1 px-4 py-2.5 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700">Hapus</button>
<button type="button" id="modal-teacher-delete-cancel" class="px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Batal</button>
</div>
</div>
</div>
</div>
<div id="toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
<script>
(function() {
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
var apiTeachers = baseUrl + '/api/academic/teachers';
var apiUsers = baseUrl + '/api/users';
var apiSubjects = baseUrl + '/api/academic/subjects';
var apiTeacherSubjects = baseUrl + '/api/academic/teacher-subjects';
var loading = document.getElementById('teachers-loading');
var errorEl = document.getElementById('teachers-error');
var content = document.getElementById('teachers-content');
var tbody = document.getElementById('teachers-tbody');
var btnAdd = document.getElementById('btn-add-teacher');
var modal = document.getElementById('modal-teacher');
var modalTitle = document.getElementById('modal-teacher-title');
var form = document.getElementById('form-teacher');
var formId = document.getElementById('form-teacher-id');
var formName = document.getElementById('form-teacher-name');
var formEmail = document.getElementById('form-teacher-email');
var formPassword = document.getElementById('form-teacher-password');
var formPasswordReq = document.getElementById('form-teacher-password-req');
var formRoleGuru = document.getElementById('form-role-guru');
var formRoleWali = document.getElementById('form-role-wali');
var formSubmit = document.getElementById('form-teacher-submit');
var formCancel = document.getElementById('form-teacher-cancel');
var modalBackdrop = document.getElementById('modal-teacher-backdrop');
var modalDelete = document.getElementById('modal-teacher-delete');
var modalDeleteMsg = document.getElementById('modal-teacher-delete-message');
var modalDeleteConfirm = document.getElementById('modal-teacher-delete-confirm');
var modalDeleteCancel = document.getElementById('modal-teacher-delete-cancel');
var modalDeleteBackdrop = document.getElementById('modal-teacher-delete-backdrop');
var toastContainer = document.getElementById('toast-container');
var deleteTargetId = null;
var modalSubjects = document.getElementById('modal-subjects');
var modalSubjectsBackdrop = document.getElementById('modal-subjects-backdrop');
var modalSubjectsTitle = document.getElementById('modal-subjects-title');
var modalSubjectsTeacher = document.getElementById('modal-subjects-teacher');
var modalSubjectsBody = document.getElementById('modal-subjects-body');
var modalSubjectsSave = document.getElementById('modal-subjects-save');
var modalSubjectsCancel = document.getElementById('modal-subjects-cancel');
var currentSubjectTeacherId = null;
var cachedSubjects = null;
function showLoading() { loading.classList.remove('hidden'); errorEl.classList.add('hidden'); content.classList.add('hidden'); }
function showError(msg) { loading.classList.add('hidden'); content.classList.add('hidden'); errorEl.classList.remove('hidden'); errorEl.textContent = msg; }
function showContent() { loading.classList.add('hidden'); errorEl.classList.add('hidden'); content.classList.remove('hidden'); }
function escapeHtml(str) { if (str == null) return ''; var d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function showToast(message, type) {
type = type || 'success';
var el = document.createElement('div');
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
}
var fetchOpts = { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } };
function postOpts(body) { return { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function putOpts(body) { return { method: 'PUT', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(body) }; }
function deleteOpts() { return { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } }; }
function roleLabel(roles) {
if (!roles || !roles.length) return '';
return roles.map(function(r) { return r.role_name || r.role_code; }).join(', ');
}
function loadTeachers() {
showLoading();
fetch(apiTeachers, fetchOpts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) { showError(r.data && r.data.message ? r.data.message : 'Gagal memuat'); return; }
var list = (r.data && r.data.data) ? r.data.data : (Array.isArray(r.data) ? r.data : []);
tbody.innerHTML = '';
list.forEach(function(row) {
var roleCodes = (row.roles || []).map(function(r) { return r.role_code; }).join(',');
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
tr.innerHTML =
'<td class="px-6 py-3 font-medium">' + escapeHtml(row.name) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(row.email) + '</td>' +
'<td class="px-6 py-3 text-gray-600 dark:text-gray-400">' + escapeHtml(roleLabel(row.roles)) + '</td>' +
'<td class="px-6 py-3 text-right">' +
'<button type="button" class="btn-edit px-3 py-1.5 rounded-lg text-primary hover:bg-primary/10" data-id="' + row.id + '" data-name="' + escapeHtml(row.name) + '" data-email="' + escapeHtml(row.email) + '" data-roles="' + escapeHtml(roleCodes) + '">Edit</button> ' +
'<button type="button" class="btn-subjects px-3 py-1.5 rounded-lg text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20" data-id="' + row.id + '" data-name="' + escapeHtml(row.name) + '">Mapel Diajar</button> ' +
'<button type="button" class="btn-delete px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20" data-id="' + row.id + '" data-name="' + escapeHtml(row.name) + '">Hapus</button>' +
'</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
formId.value = btn.getAttribute('data-id');
formName.value = btn.getAttribute('data-name') || '';
formEmail.value = btn.getAttribute('data-email') || '';
formPassword.value = '';
formPassword.placeholder = 'Kosongkan jika tidak diubah';
formPasswordReq.style.display = 'none';
var codes = (btn.getAttribute('data-roles') || '').split(',').filter(function(c) { return c; });
formRoleGuru.checked = codes.indexOf('GURU_MAPEL') !== -1;
formRoleWali.checked = codes.indexOf('WALI_KELAS') !== -1;
modalTitle.textContent = 'Edit Guru';
modal.classList.remove('hidden');
});
});
tbody.querySelectorAll('.btn-subjects').forEach(function(btn) {
btn.addEventListener('click', function() {
var tid = parseInt(btn.getAttribute('data-id'), 10);
var tname = btn.getAttribute('data-name') || '';
openSubjectsModal(tid, tname);
});
});
tbody.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteTargetId = parseInt(btn.getAttribute('data-id'), 10);
modalDeleteMsg.textContent = 'Yakin ingin menghapus "' + (btn.getAttribute('data-name') || '') + '"?';
modalDelete.classList.remove('hidden');
});
});
showContent();
})
.catch(function() { showError('Gagal memuat data'); });
}
btnAdd.addEventListener('click', function() {
formId.value = ''; formName.value = ''; formEmail.value = ''; formPassword.value = '';
formPassword.placeholder = ''; formPasswordReq.style.display = 'inline';
formRoleGuru.checked = true;
formRoleWali.checked = false;
modalTitle.textContent = 'Tambah Guru';
modal.classList.remove('hidden');
});
form.addEventListener('submit', function(e) {
e.preventDefault();
var id = formId.value ? parseInt(formId.value, 10) : null;
var roles = [];
if (formRoleGuru.checked) roles.push('GURU_MAPEL');
if (formRoleWali.checked) roles.push('WALI_KELAS');
if (!roles.length) {
showToast('Minimal pilih satu role', 'error');
return;
}
var payload = {
name: formName.value.trim(),
email: formEmail.value.trim(),
roles: roles
};
if (!id) {
payload.password = formPassword.value;
if (!payload.password) { showToast('Password wajib untuk user baru', 'error'); return; }
} else if (formPassword.value) {
payload.password = formPassword.value;
}
formSubmit.disabled = true;
var url = apiUsers;
var opts = postOpts(payload);
if (id) { url = apiUsers + '/' + id; opts = putOpts(payload); }
fetch(url, opts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
formSubmit.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan', 'error'); return; }
showToast(id ? 'Berhasil diubah' : 'Berhasil ditambahkan');
modal.classList.add('hidden');
loadTeachers();
})
.catch(function() { formSubmit.disabled = false; showToast('Gagal menyimpan', 'error'); });
});
formCancel.addEventListener('click', function() { modal.classList.add('hidden'); });
modalBackdrop.addEventListener('click', function() { modal.classList.add('hidden'); });
modalDeleteConfirm.addEventListener('click', function() {
if (!deleteTargetId) return;
var id = deleteTargetId;
modalDeleteConfirm.disabled = true;
fetch(apiUsers + '/' + id, deleteOpts())
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
modalDeleteConfirm.disabled = false;
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal menghapus', 'error'); return; }
showToast('Berhasil dihapus');
modalDelete.classList.add('hidden');
deleteTargetId = null;
loadTeachers();
})
.catch(function() { modalDeleteConfirm.disabled = false; showToast('Gagal menghapus', 'error'); });
});
modalDeleteCancel.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
modalDeleteBackdrop.addEventListener('click', function() { modalDelete.classList.add('hidden'); deleteTargetId = null; });
function loadSubjects(callback) {
if (cachedSubjects) {
if (callback) callback(cachedSubjects);
return;
}
fetch(apiSubjects, fetchOpts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) { showToast(r.data && r.data.message ? r.data.message : 'Gagal memuat mapel', 'error'); return; }
var list = (r.data && r.data.data) ? r.data.data : (Array.isArray(r.data) ? r.data : []);
cachedSubjects = list;
if (callback) callback(list);
})
.catch(function() { showToast('Gagal memuat mapel', 'error'); });
}
function openSubjectsModal(teacherId, teacherName) {
currentSubjectTeacherId = teacherId;
modalSubjectsTeacher.textContent = 'Guru: ' + teacherName;
modalSubjectsBody.innerHTML = 'Memuat...';
modalSubjects.classList.remove('hidden');
loadSubjects(function(subjects) {
fetch(apiTeacherSubjects + '/' + teacherId, fetchOpts)
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
modalSubjectsBody.textContent = (r.data && r.data.message) ? r.data.message : 'Gagal memuat data';
return;
}
var d = r.data && r.data.data ? r.data.data : r.data;
var selectedIds = (d && d.subject_ids) ? d.subject_ids : [];
var selectedSet = {};
selectedIds.forEach(function(id) { selectedSet[id] = true; });
var html = '';
subjects.forEach(function(s) {
var checked = selectedSet[s.id] ? ' checked' : '';
html += '<label class="flex items-center gap-2">' +
'<input type="checkbox" class="subject-check rounded border-gray-300 text-primary focus:ring-primary" value="' + s.id + '"' + checked + '>' +
'<span>' + escapeHtml(s.name) + '</span>' +
'</label>';
});
modalSubjectsBody.innerHTML = html || '<p class="text-sm text-gray-500">Belum ada mata pelajaran.</p>';
})
.catch(function() {
modalSubjectsBody.textContent = 'Gagal memuat data';
});
});
}
modalSubjectsCancel.addEventListener('click', function() {
modalSubjects.classList.add('hidden');
currentSubjectTeacherId = null;
});
modalSubjectsBackdrop.addEventListener('click', function() {
modalSubjects.classList.add('hidden');
currentSubjectTeacherId = null;
});
modalSubjectsSave.addEventListener('click', function() {
if (!currentSubjectTeacherId) return;
var checks = modalSubjectsBody.querySelectorAll('.subject-check');
var ids = [];
checks.forEach(function(ch) {
if (ch.checked) ids.push(parseInt(ch.value, 10));
});
fetch(apiTeacherSubjects + '/' + currentSubjectTeacherId, putOpts({ subject_ids: ids }))
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
.then(function(r) {
if (!r.ok) {
showToast(r.data && r.data.message ? r.data.message : 'Gagal menyimpan mapel', 'error');
return;
}
showToast('Mapel guru diperbarui');
modalSubjects.classList.add('hidden');
currentSubjectTeacherId = null;
})
.catch(function() {
showToast('Gagal menyimpan mapel', 'error');
});
});
loadTeachers();
})();
</script>

View File

@@ -0,0 +1,7 @@
<?php
use CodeIgniter\CLI\CLI;
CLI::error('ERROR: ' . $code);
CLI::write($message);
CLI::newLine();

View File

@@ -0,0 +1,65 @@
<?php
use CodeIgniter\CLI\CLI;
// The main Exception
CLI::write('[' . $exception::class . ']', 'light_gray', 'red');
CLI::write($message);
CLI::write('at ' . CLI::color(clean_path($exception->getFile()) . ':' . $exception->getLine(), 'green'));
CLI::newLine();
$last = $exception;
while ($prevException = $last->getPrevious()) {
$last = $prevException;
CLI::write(' Caused by:');
CLI::write(' [' . $prevException::class . ']', 'red');
CLI::write(' ' . $prevException->getMessage());
CLI::write(' at ' . CLI::color(clean_path($prevException->getFile()) . ':' . $prevException->getLine(), 'green'));
CLI::newLine();
}
// The backtrace
if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) {
$backtraces = $last->getTrace();
if ($backtraces) {
CLI::write('Backtrace:', 'green');
}
foreach ($backtraces as $i => $error) {
$padFile = ' '; // 4 spaces
$padClass = ' '; // 7 spaces
$c = str_pad($i + 1, 3, ' ', STR_PAD_LEFT);
if (isset($error['file'])) {
$filepath = clean_path($error['file']) . ':' . $error['line'];
CLI::write($c . $padFile . CLI::color($filepath, 'yellow'));
} else {
CLI::write($c . $padFile . CLI::color('[internal function]', 'yellow'));
}
$function = '';
if (isset($error['class'])) {
$type = ($error['type'] === '->') ? '()' . $error['type'] : $error['type'];
$function .= $padClass . $error['class'] . $type . $error['function'];
} elseif (! isset($error['class']) && isset($error['function'])) {
$function .= $padClass . $error['function'];
}
$args = implode(', ', array_map(static fn ($value): string => match (true) {
is_object($value) => 'Object(' . $value::class . ')',
is_array($value) => $value !== [] ? '[...]' : '[]',
$value === null => 'null', // return the lowercased version
default => var_export($value, true),
}, array_values($error['args'] ?? [])));
$function .= '(' . $args . ')';
CLI::write($function);
CLI::newLine();
}
}

View File

@@ -0,0 +1,5 @@
<?php
// On the CLI, we still want errors in productions
// so just use the exception template.
include __DIR__ . '/error_exception.php';

View File

@@ -0,0 +1,194 @@
:root {
--main-bg-color: #fff;
--main-text-color: #555;
--dark-text-color: #222;
--light-text-color: #c7c7c7;
--brand-primary-color: #DC4814;
--light-bg-color: #ededee;
--dark-bg-color: #404040;
}
body {
height: 100%;
background: var(--main-bg-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--main-text-color);
font-weight: 300;
margin: 0;
padding: 0;
}
h1 {
font-weight: lighter;
font-size: 3rem;
color: var(--dark-text-color);
margin: 0;
}
h1.headline {
margin-top: 20%;
font-size: 5rem;
}
.text-center {
text-align: center;
}
p.lead {
font-size: 1.6rem;
}
.container {
max-width: 75rem;
margin: 0 auto;
padding: 1rem;
}
.header {
background: var(--light-bg-color);
color: var(--dark-text-color);
margin-top: 2.17rem;
}
.header .container {
padding: 1rem;
}
.header h1 {
font-size: 2.5rem;
font-weight: 500;
}
.header p {
font-size: 1.2rem;
margin: 0;
line-height: 2.5;
}
.header a {
color: var(--brand-primary-color);
margin-left: 2rem;
display: none;
text-decoration: none;
}
.header:hover a {
display: inline;
}
.environment {
background: var(--brand-primary-color);
color: var(--main-bg-color);
text-align: center;
padding: calc(4px + 0.2083vw);
width: 100%;
top: 0;
position: fixed;
}
.source {
background: #343434;
color: var(--light-text-color);
padding: 0.5em 1em;
border-radius: 5px;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 0.85rem;
margin: 0;
overflow-x: scroll;
}
.source span.line {
line-height: 1.4;
}
.source span.line .number {
color: #666;
}
.source .line .highlight {
display: block;
background: var(--dark-text-color);
color: var(--light-text-color);
}
.source span.highlight .number {
color: #fff;
}
.tabs {
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
margin-bottom: -1px;
}
.tabs li {
display: inline;
}
.tabs a:link,
.tabs a:visited {
padding: 0 1rem;
line-height: 2.7;
text-decoration: none;
color: var(--dark-text-color);
background: var(--light-bg-color);
border: 1px solid rgba(0,0,0,0.15);
border-bottom: 0;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
display: inline-block;
}
.tabs a:hover {
background: var(--light-bg-color);
border-color: rgba(0,0,0,0.15);
}
.tabs a.active {
background: var(--main-bg-color);
color: var(--main-text-color);
}
.tab-content {
background: var(--main-bg-color);
border: 1px solid rgba(0,0,0,0.15);
}
.content {
padding: 1rem;
}
.hide {
display: none;
}
.alert {
margin-top: 2rem;
display: block;
text-align: center;
line-height: 3.0;
background: #d9edf7;
border: 1px solid #bcdff1;
border-radius: 5px;
color: #31708f;
}
table {
width: 100%;
overflow: hidden;
}
th {
text-align: left;
border-bottom: 1px solid #e7e7e7;
padding-bottom: 0.5rem;
}
td {
padding: 0.2rem 0.5rem 0.2rem 0;
}
tr:hover td {
background: #f1f1f1;
}
td pre {
white-space: pre-wrap;
}
.trace a {
color: inherit;
}
.trace table {
width: auto;
}
.trace tr td:first-child {
min-width: 5em;
font-weight: bold;
}
.trace td {
background: var(--light-bg-color);
padding: 0 1rem;
}
.trace td pre {
margin: 0;
}
.args {
display: none;
}

View File

@@ -0,0 +1,116 @@
var tabLinks = new Array();
var contentDivs = new Array();
function init()
{
// Grab the tab links and content divs from the page
var tabListItems = document.getElementById('tabs').childNodes;
console.log(tabListItems);
for (var i = 0; i < tabListItems.length; i ++)
{
if (tabListItems[i].nodeName == "LI")
{
var tabLink = getFirstChildWithTagName(tabListItems[i], 'A');
var id = getHash(tabLink.getAttribute('href'));
tabLinks[id] = tabLink;
contentDivs[id] = document.getElementById(id);
}
}
// Assign onclick events to the tab links, and
// highlight the first tab
var i = 0;
for (var id in tabLinks)
{
tabLinks[id].onclick = showTab;
tabLinks[id].onfocus = function () {
this.blur()
};
if (i == 0)
{
tabLinks[id].className = 'active';
}
i ++;
}
// Hide all content divs except the first
var i = 0;
for (var id in contentDivs)
{
if (i != 0)
{
console.log(contentDivs[id]);
contentDivs[id].className = 'content hide';
}
i ++;
}
}
function showTab()
{
var selectedId = getHash(this.getAttribute('href'));
// Highlight the selected tab, and dim all others.
// Also show the selected content div, and hide all others.
for (var id in contentDivs)
{
if (id == selectedId)
{
tabLinks[id].className = 'active';
contentDivs[id].className = 'content';
}
else
{
tabLinks[id].className = '';
contentDivs[id].className = 'content hide';
}
}
// Stop the browser following the link
return false;
}
function getFirstChildWithTagName(element, tagName)
{
for (var i = 0; i < element.childNodes.length; i ++)
{
if (element.childNodes[i].nodeName == tagName)
{
return element.childNodes[i];
}
}
}
function getHash(url)
{
var hashPos = url.lastIndexOf('#');
return url.substring(hashPos + 1);
}
function toggle(elem)
{
elem = document.getElementById(elem);
if (elem.style && elem.style['display'])
{
// Only works with the "style" attr
var disp = elem.style['display'];
}
else if (elem.currentStyle)
{
// For MSIE, naturally
var disp = elem.currentStyle['display'];
}
else if (window.getComputedStyle)
{
// For most other browsers
var disp = document.defaultView.getComputedStyle(elem, null).getPropertyValue('display');
}
// Toggle the state of the "display" style
elem.style.display = disp == 'block' ? 'none' : 'block';
return false;
}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= lang('Errors.badRequest') ?></title>
<style>
div.logo {
height: 200px;
width: 155px;
display: inline-block;
opacity: 0.08;
position: absolute;
top: 2rem;
left: 50%;
margin-left: -73px;
}
body {
height: 100%;
background: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #777;
font-weight: 300;
}
h1 {
font-weight: lighter;
letter-spacing: normal;
font-size: 3rem;
margin-top: 0;
margin-bottom: 0;
color: #222;
}
.wrap {
max-width: 1024px;
margin: 5rem auto;
padding: 2rem;
background: #fff;
text-align: center;
border: 1px solid #efefef;
border-radius: 0.5rem;
position: relative;
}
pre {
white-space: normal;
margin-top: 1.5rem;
}
code {
background: #fafafa;
border: 1px solid #efefef;
padding: 0.5rem 1rem;
border-radius: 5px;
display: block;
}
p {
margin-top: 1.5rem;
}
.footer {
margin-top: 2rem;
border-top: 1px solid #efefef;
padding: 1em 2em 0 2em;
font-size: 85%;
color: #999;
}
a:active,
a:link,
a:visited {
color: #dd4814;
}
</style>
</head>
<body>
<div class="wrap">
<h1>400</h1>
<p>
<?php if (ENVIRONMENT !== 'production') : ?>
<?= nl2br(esc($message)) ?>
<?php else : ?>
<?= lang('Errors.sorryBadRequest') ?>
<?php endif; ?>
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= lang('Errors.pageNotFound') ?></title>
<style>
div.logo {
height: 200px;
width: 155px;
display: inline-block;
opacity: 0.08;
position: absolute;
top: 2rem;
left: 50%;
margin-left: -73px;
}
body {
height: 100%;
background: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #777;
font-weight: 300;
}
h1 {
font-weight: lighter;
letter-spacing: normal;
font-size: 3rem;
margin-top: 0;
margin-bottom: 0;
color: #222;
}
.wrap {
max-width: 1024px;
margin: 5rem auto;
padding: 2rem;
background: #fff;
text-align: center;
border: 1px solid #efefef;
border-radius: 0.5rem;
position: relative;
}
pre {
white-space: normal;
margin-top: 1.5rem;
}
code {
background: #fafafa;
border: 1px solid #efefef;
padding: 0.5rem 1rem;
border-radius: 5px;
display: block;
}
p {
margin-top: 1.5rem;
}
.footer {
margin-top: 2rem;
border-top: 1px solid #efefef;
padding: 1em 2em 0 2em;
font-size: 85%;
color: #999;
}
a:active,
a:link,
a:visited {
color: #dd4814;
}
</style>
</head>
<body>
<div class="wrap">
<h1>404</h1>
<p>
<?php if (ENVIRONMENT !== 'production') : ?>
<?= nl2br(esc($message)) ?>
<?php else : ?>
<?= lang('Errors.sorryCannotFind') ?>
<?php endif; ?>
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,429 @@
<?php
use CodeIgniter\HTTP\Header;
use CodeIgniter\CodeIgniter;
$errorId = uniqid('error', true);
?>
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title><?= esc($title) ?></title>
<style>
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
</style>
<script>
<?= file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.js') ?>
</script>
</head>
<body onload="init()">
<!-- Header -->
<div class="header">
<div class="environment">
Displayed at <?= esc(date('H:i:s')) ?> &mdash;
PHP: <?= esc(PHP_VERSION) ?> &mdash;
CodeIgniter: <?= esc(CodeIgniter::CI_VERSION) ?> --
Environment: <?= ENVIRONMENT ?>
</div>
<div class="container">
<h1><?= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?></h1>
<p>
<?= nl2br(esc($exception->getMessage())) ?>
<a href="https://www.duckduckgo.com/?q=<?= urlencode($title . ' ' . preg_replace('#\'.*\'|".*"#Us', '', $exception->getMessage())) ?>"
rel="noreferrer" target="_blank">search &rarr;</a>
</p>
</div>
</div>
<!-- Source -->
<div class="container">
<p><b><?= esc(clean_path($file)) ?></b> at line <b><?= esc($line) ?></b></p>
<?php if (is_file($file)) : ?>
<div class="source">
<?= static::highlightFile($file, $line, 15); ?>
</div>
<?php endif; ?>
</div>
<div class="container">
<?php
$last = $exception;
while ($prevException = $last->getPrevious()) {
$last = $prevException;
?>
<pre>
Caused by:
<?= esc($prevException::class), esc($prevException->getCode() ? ' #' . $prevException->getCode() : '') ?>
<?= nl2br(esc($prevException->getMessage())) ?>
<a href="https://www.duckduckgo.com/?q=<?= urlencode($prevException::class . ' ' . preg_replace('#\'.*\'|".*"#Us', '', $prevException->getMessage())) ?>"
rel="noreferrer" target="_blank">search &rarr;</a>
<?= esc(clean_path($prevException->getFile()) . ':' . $prevException->getLine()) ?>
</pre>
<?php
}
?>
</div>
<?php if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) : ?>
<div class="container">
<ul class="tabs" id="tabs">
<li><a href="#backtrace">Backtrace</a></li>
<li><a href="#server">Server</a></li>
<li><a href="#request">Request</a></li>
<li><a href="#response">Response</a></li>
<li><a href="#files">Files</a></li>
<li><a href="#memory">Memory</a></li>
</ul>
<div class="tab-content">
<!-- Backtrace -->
<div class="content" id="backtrace">
<ol class="trace">
<?php foreach ($trace as $index => $row) : ?>
<li>
<p>
<!-- Trace info -->
<?php if (isset($row['file']) && is_file($row['file'])) : ?>
<?php
if (isset($row['function']) && in_array($row['function'], ['include', 'include_once', 'require', 'require_once'], true)) {
echo esc($row['function'] . ' ' . clean_path($row['file']));
} else {
echo esc(clean_path($row['file']) . ' : ' . $row['line']);
}
?>
<?php else: ?>
{PHP internal code}
<?php endif; ?>
<!-- Class/Method -->
<?php if (isset($row['class'])) : ?>
&nbsp;&nbsp;&mdash;&nbsp;&nbsp;<?= esc($row['class'] . $row['type'] . $row['function']) ?>
<?php if (! empty($row['args'])) : ?>
<?php $argsId = $errorId . 'args' . $index ?>
( <a href="#" onclick="return toggle('<?= esc($argsId, 'attr') ?>');">arguments</a> )
<div class="args" id="<?= esc($argsId, 'attr') ?>">
<table cellspacing="0">
<?php
$params = null;
// Reflection by name is not available for closure function
if (! str_ends_with($row['function'], '}')) {
$mirror = isset($row['class']) ? new ReflectionMethod($row['class'], $row['function']) : new ReflectionFunction($row['function']);
$params = $mirror->getParameters();
}
foreach ($row['args'] as $key => $value) : ?>
<tr>
<td><code><?= esc(isset($params[$key]) ? '$' . $params[$key]->name : "#{$key}") ?></code></td>
<td><pre><?= esc(print_r($value, true)) ?></pre></td>
</tr>
<?php endforeach ?>
</table>
</div>
<?php else : ?>
()
<?php endif; ?>
<?php endif; ?>
<?php if (! isset($row['class']) && isset($row['function'])) : ?>
&nbsp;&nbsp;&mdash;&nbsp;&nbsp; <?= esc($row['function']) ?>()
<?php endif; ?>
</p>
<!-- Source? -->
<?php if (isset($row['file']) && is_file($row['file']) && isset($row['class'])) : ?>
<div class="source">
<?= static::highlightFile($row['file'], $row['line']) ?>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</div>
<!-- Server -->
<div class="content" id="server">
<?php foreach (['_SERVER', '_SESSION'] as $var) : ?>
<?php
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
continue;
} ?>
<h3>$<?= esc($var) ?></h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach ?>
<!-- Constants -->
<?php $constants = get_defined_constants(true); ?>
<?php if (! empty($constants['user'])) : ?>
<h3>Constants</h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($constants['user'] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Request -->
<div class="content" id="request">
<?php $request = service('request'); ?>
<table>
<tbody>
<tr>
<td style="width: 10em">Path</td>
<td><?= esc($request->getUri()) ?></td>
</tr>
<tr>
<td>HTTP Method</td>
<td><?= esc($request->getMethod()) ?></td>
</tr>
<tr>
<td>IP Address</td>
<td><?= esc($request->getIPAddress()) ?></td>
</tr>
<tr>
<td style="width: 10em">Is AJAX Request?</td>
<td><?= $request->isAJAX() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>Is CLI Request?</td>
<td><?= $request->isCLI() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>Is Secure Request?</td>
<td><?= $request->isSecure() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>User Agent</td>
<td><?= esc($request->getUserAgent()->getAgentString()) ?></td>
</tr>
</tbody>
</table>
<?php $empty = true; ?>
<?php foreach (['_GET', '_POST', '_COOKIE'] as $var) : ?>
<?php
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
continue;
} ?>
<?php $empty = false; ?>
<h3>$<?= esc($var) ?></h3>
<table style="width: 100%">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach ?>
<?php if ($empty) : ?>
<div class="alert">
No $_GET, $_POST, or $_COOKIE Information to show.
</div>
<?php endif; ?>
<?php $headers = $request->headers(); ?>
<?php if (! empty($headers)) : ?>
<h3>Headers</h3>
<table>
<thead>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($headers as $name => $value) : ?>
<tr>
<td><?= esc($name, 'html') ?></td>
<td>
<?php
if ($value instanceof Header) {
echo esc($value->getValueLine(), 'html');
} else {
foreach ($value as $i => $header) {
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
}
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Response -->
<?php
$response = service('response');
$response->setStatusCode(http_response_code());
?>
<div class="content" id="response">
<table>
<tr>
<td style="width: 15em">Response Status</td>
<td><?= esc($response->getStatusCode() . ' - ' . $response->getReasonPhrase()) ?></td>
</tr>
</table>
<?php $headers = $response->headers(); ?>
<?php if (! empty($headers)) : ?>
<h3>Headers</h3>
<table>
<thead>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($headers as $name => $value) : ?>
<tr>
<td><?= esc($name, 'html') ?></td>
<td>
<?php
if ($value instanceof Header) {
echo esc($response->getHeaderLine($name), 'html');
} else {
foreach ($value as $i => $header) {
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
}
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Files -->
<div class="content" id="files">
<?php $files = get_included_files(); ?>
<ol>
<?php foreach ($files as $file) :?>
<li><?= esc(clean_path($file)) ?></li>
<?php endforeach ?>
</ol>
</div>
<!-- Memory -->
<div class="content" id="memory">
<table>
<tbody>
<tr>
<td>Memory Usage</td>
<td><?= esc(static::describeMemory(memory_get_usage(true))) ?></td>
</tr>
<tr>
<td style="width: 12em">Peak Memory Usage:</td>
<td><?= esc(static::describeMemory(memory_get_peak_usage(true))) ?></td>
</tr>
<tr>
<td>Memory Limit:</td>
<td><?= esc(ini_get('memory_limit')) ?></td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /tab-content -->
</div> <!-- /container -->
<?php endif; ?>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title><?= lang('Errors.whoops') ?></title>
<style>
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
</style>
</head>
<body>
<div class="container text-center">
<h1 class="headline"><?= lang('Errors.whoops') ?></h1>
<p class="lead"><?= lang('Errors.weHitASnag') ?></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<?php
$authService = new \App\Modules\Auth\Services\AuthService();
$currentUser = $authService->currentUser();
$currentUri = service('request')->getPath();
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= esc($title ?? 'Dashboard') ?> | SMAN 1 Garut</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { theme: { extend: { colors: { primary: { DEFAULT: '#3C50E0', hover: '#2E43C7' } } } } };
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/boxicons@2.1.4/css/boxicons.min.css">
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200">
<div class="flex min-h-screen">
<?= view('partials/sidebar', ['user' => $currentUser, 'current_uri' => $currentUri]) ?>
<div class="flex-1 flex flex-col">
<?= view('partials/header', ['user' => $currentUser ?? $user ?? null]) ?>
<main class="flex-1 p-6 overflow-auto">
<?= $content ?>
</main>
<?= view('partials/footer') ?>
</div>
</div>
<?= view('partials/scripts') ?>
</body>
</html>

67
app/Views/login/index.php Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login | SMAN 1 Garut</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { theme: { extend: { colors: { primary: { DEFAULT: '#3C50E0' } } } } };</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/boxicons@2.1.4/css/boxicons.min.css">
</head>
<body class="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">SMAN 1 Garut</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">Sign in to dashboard</p>
</div>
<form id="login-form" class="space-y-5">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input type="email" id="email" name="email" required autocomplete="email"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<p id="login-error" class="text-sm text-red-600 dark:text-red-400 hidden"></p>
<button type="submit" id="login-btn" class="w-full py-2.5 px-4 rounded-lg font-medium text-white bg-primary hover:opacity-90 focus:ring-2 focus:ring-primary focus:ring-offset-2">
Sign in
</button>
</form>
</div>
</div>
<script>
(function() {
var form = document.getElementById('login-form');
var errorEl = document.getElementById('login-error');
var btn = document.getElementById('login-btn');
var apiLogin = '<?= base_url('api/auth/login') ?>';
var dashboardUrl = '<?= base_url('dashboard') ?>';
form.addEventListener('submit', function(e) {
e.preventDefault();
errorEl.classList.add('hidden');
errorEl.textContent = '';
btn.disabled = true;
fetch(apiLogin, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
body: JSON.stringify({ email: document.getElementById('email').value, password: document.getElementById('password').value })
}).then(function(r) {
if (r.ok) return r.json();
return r.json().then(function(j) { throw new Error(j.message || 'Login failed'); });
}).then(function() {
window.location.href = dashboardUrl;
}).catch(function(err) {
errorEl.textContent = err.message || 'Invalid email or password';
errorEl.classList.remove('hidden');
btn.disabled = false;
});
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
<footer class="py-3 px-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500 dark:text-gray-400">
&copy; <?= date('Y') ?> SMAN 1 Garut. All rights reserved.
</footer>

View File

@@ -0,0 +1,19 @@
<header class="sticky top-0 z-30 flex items-center justify-between h-16 px-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div class="flex items-center gap-4">
<button type="button" id="sidebar-toggle" class="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700" aria-label="Toggle sidebar">
<i class="bx bx-menu text-2xl"></i>
</button>
<h1 class="text-lg font-semibold hidden sm:block">Dashboard</h1>
</div>
<div class="flex items-center gap-3">
<?php if (!empty($user)): ?>
<span class="text-sm text-gray-600 dark:text-gray-400"><?= esc($user['name']) ?></span>
<form id="logout-form" method="post" action="<?= base_url('logout') ?>" class="inline">
<?= csrf_field() ?>
<button type="submit" class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:opacity-90 focus:ring-2 focus:ring-primary/20">
<i class="bx bx-log-out"></i> Logout
</button>
</form>
<?php endif; ?>
</div>
</header>

View File

@@ -0,0 +1,20 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
var sidebar = document.getElementById('sidebar');
var toggle = document.getElementById('sidebar-toggle');
var backdrop = document.getElementById('sidebar-backdrop');
if (toggle && sidebar) {
toggle.addEventListener('click', function() {
sidebar.classList.toggle('-translate-x-full');
if (backdrop) backdrop.classList.toggle('hidden');
});
}
if (backdrop && sidebar) {
backdrop.addEventListener('click', function() {
sidebar.classList.add('-translate-x-full');
backdrop.classList.add('hidden');
});
}
// Logout form submits normally (POST to /logout, then redirect by server)
});
</script>

View File

@@ -0,0 +1,63 @@
<?php
$user = $user ?? null;
$currentUri = $current_uri ?? '';
$roleCodes = $user && !empty($user['roles']) ? array_column($user['roles'], 'role_code') : [];
$isAdmin = in_array('ADMIN', $roleCodes, true);
$isParent = in_array('ORANG_TUA', $roleCodes, true);
$navClassActive = 'flex items-center gap-3 px-4 py-3 rounded-lg bg-primary/10 text-primary font-medium';
$navClassInactive = 'flex items-center gap-3 px-4 py-3 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700';
function nav_is_active(string $currentUri, string $path, bool $exact = true): bool {
if ($exact) {
return $currentUri === $path;
}
return $currentUri === $path || strpos($currentUri, $path . '/') === 0;
}
?>
<aside id="sidebar" class="w-64 min-h-screen bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transform -translate-x-full lg:translate-x-0 transition-transform duration-200 fixed lg:static inset-y-0 left-0 z-40">
<div class="flex flex-col h-full">
<div class="flex items-center h-16 px-6 border-b border-gray-200 dark:border-gray-700">
<a href="<?= base_url('dashboard') ?>" class="text-xl font-bold text-primary dark:text-primary-400">SMAN 1 Garut</a>
</div>
<nav class="flex-1 p-4 space-y-1 overflow-y-auto">
<a href="<?= base_url('dashboard') ?>" class="<?= nav_is_active($currentUri, 'dashboard') ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-home-alt text-xl"></i>
<span>Dashboard</span>
</a>
<a href="<?= base_url('dashboard/schedule/today') ?>" class="<?= nav_is_active($currentUri, 'dashboard/schedule/today') ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-calendar text-xl"></i>
<span>Jadwal Hari Ini</span>
</a>
<a href="<?= base_url('dashboard/attendance/reports') ?>" class="<?= nav_is_active($currentUri, 'dashboard/attendance/reports') ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-calendar-check text-xl"></i>
<span>Laporan Absensi</span>
</a>
<a href="<?= base_url('dashboard/discipline') ?>" class="<?= nav_is_active($currentUri, 'dashboard/discipline') ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-error-alt text-xl"></i>
<span>Poin Pelanggaran</span>
</a>
<?php if ($isParent): ?>
<a href="<?= base_url('dashboard/parent') ?>" class="<?= nav_is_active($currentUri, 'dashboard/parent') ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-user-circle text-xl"></i>
<span>Portal Orang Tua</span>
</a>
<?php endif; ?>
<?php if ($isAdmin): ?>
<a href="<?= base_url('dashboard/presence-settings') ?>" class="<?= nav_is_active($currentUri, 'dashboard/presence-settings') ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-map-pin text-xl"></i>
<span>Pengaturan Presensi</span>
</a>
<a href="<?= base_url('dashboard/academic/settings') ?>" class="<?= nav_is_active($currentUri, 'dashboard/academic', false) ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-cog text-xl"></i>
<span>Pengaturan Academic</span>
</a>
<a href="<?= base_url('dashboard/devices') ?>" class="<?= nav_is_active($currentUri, 'dashboard/devices') ? $navClassActive : $navClassInactive ?>">
<i class="bx bx-devices text-xl"></i>
<span>Device Absen</span>
</a>
<?php endif; ?>
</nav>
</div>
</aside>
<div id="sidebar-backdrop" class="fixed inset-0 bg-black/50 z-30 lg:hidden hidden" aria-hidden="true"></div>