Files
presensi/app/Views/dashboard/dapodik.php
2026-03-05 14:37:36 +07:00

256 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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