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,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>