init backend presensi
This commit is contained in:
255
app/Views/dashboard/dapodik.php
Normal file
255
app/Views/dashboard/dapodik.php
Normal 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>
|
||||
Reference in New Issue
Block a user