981 lines
46 KiB
JavaScript
981 lines
46 KiB
JavaScript
import './bootstrap';
|
|
|
|
import Alpine from 'alpinejs';
|
|
import Swal from 'sweetalert2';
|
|
|
|
window.Alpine = Alpine;
|
|
window.Swal = Swal;
|
|
|
|
Alpine.start();
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.querySelectorAll('.delete-form').forEach((form) => {
|
|
form.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
|
|
const entity = form.dataset.entity || 'data ini';
|
|
const title = form.dataset.confirmTitle || 'Hapus data?';
|
|
const text = form.dataset.confirmText || `Yakin ingin menghapus ${entity}? Tindakan ini tidak bisa dibatalkan.`;
|
|
const confirmButtonColor = form.dataset.confirmButtonColor || '#e11d48';
|
|
const confirmButtonText = form.dataset.confirmButtonText || 'Ya, hapus';
|
|
|
|
Swal.fire({
|
|
title,
|
|
text,
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonColor,
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText,
|
|
cancelButtonText: 'Batal',
|
|
reverseButtons: true,
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
form.submit();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
const passwordInput = document.querySelector('[data-password-input]');
|
|
const passwordToggle = document.querySelector('[data-password-toggle]');
|
|
|
|
document.querySelectorAll('[data-searchable-student-select]').forEach((wrapper) => {
|
|
const input = wrapper.querySelector('[data-student-search-input]');
|
|
const hiddenInput = wrapper.querySelector('[data-student-search-value]');
|
|
const toggle = wrapper.querySelector('[data-student-search-toggle]');
|
|
const dropdown = wrapper.querySelector('[data-student-search-dropdown]');
|
|
const options = Array.from(wrapper.querySelectorAll('[data-student-option]'));
|
|
const emptyState = wrapper.querySelector('[data-student-empty-state]');
|
|
|
|
if (!input || !hiddenInput || !toggle || !dropdown || options.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const closeDropdown = () => {
|
|
dropdown.classList.add('hidden');
|
|
};
|
|
|
|
const openDropdown = () => {
|
|
dropdown.classList.remove('hidden');
|
|
};
|
|
|
|
const filterOptions = () => {
|
|
const keyword = input.value.trim().toLowerCase();
|
|
let visibleCount = 0;
|
|
|
|
options.forEach((option) => {
|
|
const haystack = option.dataset.search || '';
|
|
const match = keyword === '' || haystack.includes(keyword);
|
|
option.classList.toggle('hidden', !match);
|
|
if (match) visibleCount += 1;
|
|
});
|
|
|
|
if (emptyState) {
|
|
emptyState.classList.toggle('hidden', visibleCount > 0);
|
|
}
|
|
};
|
|
|
|
input.addEventListener('focus', () => {
|
|
filterOptions();
|
|
openDropdown();
|
|
});
|
|
|
|
input.addEventListener('input', () => {
|
|
hiddenInput.value = '';
|
|
filterOptions();
|
|
openDropdown();
|
|
});
|
|
|
|
toggle.addEventListener('click', () => {
|
|
filterOptions();
|
|
dropdown.classList.toggle('hidden');
|
|
});
|
|
|
|
options.forEach((option) => {
|
|
option.addEventListener('click', () => {
|
|
hiddenInput.value = option.dataset.value || '';
|
|
input.value = option.dataset.label || '';
|
|
closeDropdown();
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
if (!wrapper.contains(event.target)) {
|
|
closeDropdown();
|
|
}
|
|
});
|
|
});
|
|
|
|
const lightboxTriggers = document.querySelectorAll('[data-lightbox-trigger]');
|
|
|
|
if (lightboxTriggers.length > 0) {
|
|
const lightbox = document.createElement('div');
|
|
lightbox.className = 'fixed inset-0 z-[10000] hidden items-center justify-center bg-slate-950/85 px-4 py-6';
|
|
lightbox.innerHTML = `
|
|
<div class="absolute inset-0" data-lightbox-close></div>
|
|
<div class="relative z-10 flex max-h-full w-full max-w-5xl flex-col items-center justify-center gap-3">
|
|
<div class="flex w-full items-center justify-between gap-4 rounded-2xl bg-slate-900/80 px-4 py-3 text-white shadow-2xl backdrop-blur">
|
|
<div class="truncate text-sm font-semibold" data-lightbox-title>Preview Gambar</div>
|
|
<button type="button" class="rounded-xl border border-white/15 px-3 py-2 text-xs font-semibold text-white transition hover:bg-white/10" data-lightbox-close>Tutup</button>
|
|
</div>
|
|
<div class="flex max-h-[calc(100vh-110px)] w-full items-center justify-center overflow-auto rounded-2xl bg-slate-900/60 p-3 shadow-2xl backdrop-blur">
|
|
<img src="" alt="Preview" class="max-h-[calc(100vh-140px)] w-auto max-w-full rounded-xl object-contain shadow-2xl" data-lightbox-image>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(lightbox);
|
|
|
|
const lightboxImage = lightbox.querySelector('[data-lightbox-image]');
|
|
const lightboxTitle = lightbox.querySelector('[data-lightbox-title]');
|
|
const closeLightbox = () => {
|
|
lightbox.classList.add('hidden');
|
|
lightbox.classList.remove('flex');
|
|
document.body.classList.remove('overflow-hidden');
|
|
if (lightboxImage) {
|
|
lightboxImage.setAttribute('src', '');
|
|
}
|
|
};
|
|
|
|
lightbox.querySelectorAll('[data-lightbox-close]').forEach((element) => {
|
|
element.addEventListener('click', closeLightbox);
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
closeLightbox();
|
|
}
|
|
});
|
|
|
|
lightboxTriggers.forEach((trigger) => {
|
|
trigger.addEventListener('click', () => {
|
|
const src = trigger.dataset.lightboxSrc || '';
|
|
const title = trigger.dataset.lightboxTitle || 'Preview Gambar';
|
|
|
|
if (!src || !lightboxImage || !lightboxTitle) {
|
|
return;
|
|
}
|
|
|
|
lightboxImage.setAttribute('src', src);
|
|
lightboxTitle.textContent = title;
|
|
lightbox.classList.remove('hidden');
|
|
lightbox.classList.add('flex');
|
|
document.body.classList.add('overflow-hidden');
|
|
});
|
|
});
|
|
}
|
|
|
|
if (passwordInput && passwordToggle) {
|
|
passwordToggle.addEventListener('click', () => {
|
|
const isPassword = passwordInput.getAttribute('type') === 'password';
|
|
passwordInput.setAttribute('type', isPassword ? 'text' : 'password');
|
|
passwordToggle.setAttribute('aria-label', isPassword ? 'Sembunyikan password' : 'Tampilkan password');
|
|
});
|
|
}
|
|
|
|
const documentTypeSelect = document.querySelector('[data-document-type-select]');
|
|
const maxFileSizeInfo = document.querySelector('[data-max-file-size-info]');
|
|
const allowedExtensionsInfo = document.querySelector('[data-allowed-extensions-info]');
|
|
|
|
if (documentTypeSelect && maxFileSizeInfo && allowedExtensionsInfo) {
|
|
const updateDocumentTypeInfo = () => {
|
|
const selectedOption = documentTypeSelect.options[documentTypeSelect.selectedIndex];
|
|
const maxSizeKb = Number(selectedOption?.dataset?.maxSizeKb || 5120);
|
|
const maxSizeMb = (maxSizeKb / 1024).toFixed(2);
|
|
const extensions = (selectedOption?.dataset?.extensions || 'jpg,jpeg,png,pdf,webp')
|
|
.split(',')
|
|
.map((item) => item.trim().toUpperCase())
|
|
.join(', ');
|
|
|
|
maxFileSizeInfo.textContent = `Maksimal ukuran file untuk jenis dokumen ini: ${maxSizeMb} MB`;
|
|
allowedExtensionsInfo.textContent = `Format diizinkan: ${extensions}.`;
|
|
};
|
|
|
|
documentTypeSelect.addEventListener('change', updateDocumentTypeInfo);
|
|
updateDocumentTypeInfo();
|
|
}
|
|
|
|
const studentHoverTriggers = document.querySelectorAll('.student-hover-trigger');
|
|
|
|
if (studentHoverTriggers.length > 0) {
|
|
const hoverCard = document.createElement('div');
|
|
hoverCard.className = 'pointer-events-none fixed z-[9999] hidden w-64 overflow-hidden rounded-[20px] border border-slate-200 bg-white p-2.5 shadow-[0_20px_45px_-20px_rgba(15,23,42,0.35)] transition-opacity duration-150';
|
|
document.body.appendChild(hoverCard);
|
|
|
|
const renderHoverCard = (trigger) => {
|
|
const photoUrl = trigger.dataset.photoUrl;
|
|
const studentName = trigger.dataset.studentName || '-';
|
|
const studentNis = trigger.dataset.studentNis || '-';
|
|
const studentClass = trigger.dataset.studentClass || '-';
|
|
const studentDepartment = trigger.dataset.studentDepartment || '-';
|
|
const studentInitial = studentName.charAt(0).toUpperCase();
|
|
|
|
hoverCard.innerHTML = `
|
|
<div class="absolute inset-x-0 top-0 h-12 rounded-t-[20px] bg-gradient-to-r from-indigo-100 via-sky-50 to-white"></div>
|
|
<div class="relative flex items-start gap-3">
|
|
${photoUrl
|
|
? `<div class="relative shrink-0">
|
|
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-white/70 to-transparent"></div>
|
|
<img src="${photoUrl}" alt="${studentName}" class="h-20 w-20 rounded-2xl object-cover shadow-[0_10px_24px_-14px_rgba(15,23,42,0.6)] ring-1 ring-slate-200 bg-slate-100">
|
|
</div>`
|
|
: `<div class="flex h-20 w-20 shrink-0 flex-col items-center justify-center rounded-2xl bg-gradient-to-br from-slate-100 via-white to-slate-200 text-center text-slate-500 shadow-[0_10px_24px_-14px_rgba(15,23,42,0.35)] ring-1 ring-slate-200">
|
|
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-white text-sm font-bold text-slate-700 shadow-sm">${studentInitial}</div>
|
|
<div class="mt-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-slate-400">No Photo</div>
|
|
</div>`}
|
|
<div class="min-w-0 flex-1 pt-0.5">
|
|
<div class="text-sm font-semibold leading-tight text-slate-800 line-clamp-2">${studentName}</div>
|
|
<div class="mt-1 inline-flex rounded-full bg-slate-100/80 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-slate-500">NIS · ${studentNis}</div>
|
|
<div class="mt-2 h-px w-full bg-gradient-to-r from-slate-200 via-slate-100 to-transparent"></div>
|
|
<div class="mt-2 text-[11px] font-medium leading-snug text-slate-600">${studentClass}</div>
|
|
<div class="mt-0.5 text-[11px] leading-snug text-slate-400">${studentDepartment}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
const positionHoverCard = (trigger) => {
|
|
const rect = trigger.getBoundingClientRect();
|
|
const cardWidth = 256;
|
|
const cardHeight = 120;
|
|
let left = rect.left;
|
|
let top = rect.bottom + 12;
|
|
|
|
if (left + cardWidth > window.innerWidth - 16) {
|
|
left = window.innerWidth - cardWidth - 16;
|
|
}
|
|
|
|
if (top + cardHeight > window.innerHeight - 16) {
|
|
top = rect.top - cardHeight - 12;
|
|
}
|
|
|
|
if (top < 16) {
|
|
top = 16;
|
|
}
|
|
|
|
hoverCard.style.left = `${Math.max(left, 16)}px`;
|
|
hoverCard.style.top = `${top}px`;
|
|
};
|
|
|
|
const showHoverCard = (trigger) => {
|
|
renderHoverCard(trigger);
|
|
positionHoverCard(trigger);
|
|
hoverCard.classList.remove('hidden');
|
|
hoverCard.style.opacity = '1';
|
|
};
|
|
|
|
const hideHoverCard = () => {
|
|
hoverCard.style.opacity = '0';
|
|
hoverCard.classList.add('hidden');
|
|
};
|
|
|
|
studentHoverTriggers.forEach((trigger) => {
|
|
trigger.addEventListener('mouseenter', () => showHoverCard(trigger));
|
|
trigger.addEventListener('mouseleave', hideHoverCard);
|
|
trigger.addEventListener('focus', () => showHoverCard(trigger));
|
|
trigger.addEventListener('blur', hideHoverCard);
|
|
});
|
|
|
|
window.addEventListener('scroll', hideHoverCard, true);
|
|
window.addEventListener('resize', hideHoverCard);
|
|
}
|
|
|
|
const setupBulkDelete = ({ formSelector, selectAllSelector, rowCheckboxSelector, buttonSelector, title, textBuilder, confirmText }) => {
|
|
const form = document.querySelector(formSelector);
|
|
|
|
if (!form) {
|
|
return;
|
|
}
|
|
|
|
const selectAllCheckbox = document.querySelector(selectAllSelector);
|
|
const rowCheckboxes = form.querySelectorAll(rowCheckboxSelector);
|
|
const bulkDeleteButton = document.querySelector(buttonSelector);
|
|
|
|
const syncBulkDeleteState = () => {
|
|
const checkedCount = Array.from(rowCheckboxes).filter((checkbox) => checkbox.checked).length;
|
|
const enabledCheckboxes = Array.from(rowCheckboxes).filter((checkbox) => !checkbox.disabled);
|
|
|
|
if (bulkDeleteButton) {
|
|
bulkDeleteButton.disabled = checkedCount === 0;
|
|
bulkDeleteButton.classList.toggle('opacity-50', checkedCount === 0);
|
|
bulkDeleteButton.classList.toggle('cursor-not-allowed', checkedCount === 0);
|
|
}
|
|
|
|
if (selectAllCheckbox) {
|
|
selectAllCheckbox.checked = checkedCount > 0 && enabledCheckboxes.length > 0 && checkedCount === enabledCheckboxes.length;
|
|
selectAllCheckbox.indeterminate = checkedCount > 0 && checkedCount < enabledCheckboxes.length;
|
|
}
|
|
};
|
|
|
|
selectAllCheckbox?.addEventListener('change', () => {
|
|
rowCheckboxes.forEach((checkbox) => {
|
|
if (!checkbox.disabled) {
|
|
checkbox.checked = selectAllCheckbox.checked;
|
|
}
|
|
});
|
|
syncBulkDeleteState();
|
|
});
|
|
|
|
rowCheckboxes.forEach((checkbox) => {
|
|
checkbox.addEventListener('change', syncBulkDeleteState);
|
|
});
|
|
|
|
form.addEventListener('submit', (event) => {
|
|
const checkedCount = Array.from(rowCheckboxes).filter((checkbox) => checkbox.checked).length;
|
|
|
|
if (checkedCount === 0) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
Swal.fire({
|
|
title,
|
|
text: textBuilder(checkedCount),
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonColor: '#e11d48',
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText: confirmText,
|
|
cancelButtonText: 'Batal',
|
|
reverseButtons: true,
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
form.submit();
|
|
}
|
|
});
|
|
});
|
|
|
|
syncBulkDeleteState();
|
|
};
|
|
|
|
setupBulkDelete({
|
|
formSelector: '#bulk-delete-students-form',
|
|
selectAllSelector: '#select-all-students',
|
|
rowCheckboxSelector: '.student-row-checkbox',
|
|
buttonSelector: '#bulk-delete-students-button',
|
|
title: 'Hapus banyak data siswa?',
|
|
textBuilder: (checkedCount) => `${checkedCount} data siswa yang dipilih akan dihapus. Tindakan ini tidak bisa dibatalkan.`,
|
|
confirmText: 'Ya, hapus semua',
|
|
});
|
|
|
|
setupBulkDelete({
|
|
formSelector: '#bulk-delete-classes-form',
|
|
selectAllSelector: '#select-all-classes',
|
|
rowCheckboxSelector: '.class-row-checkbox',
|
|
buttonSelector: '#bulk-delete-classes-button',
|
|
title: 'Hapus banyak kelas?',
|
|
textBuilder: (checkedCount) => `${checkedCount} kelas yang dipilih akan dihapus. Tindakan ini tidak bisa dibatalkan.`,
|
|
confirmText: 'Ya, hapus semua',
|
|
});
|
|
|
|
const bulkUploadInput = document.querySelector('[data-bulk-upload-input]');
|
|
const bulkUploadDropzone = document.querySelector('[data-bulk-upload-dropzone]');
|
|
const bulkUploadDropzoneInner = document.querySelector('[data-bulk-upload-dropzone-inner]');
|
|
const bulkUploadSummary = document.querySelector('[data-bulk-upload-summary]');
|
|
const bulkUploadFileList = document.querySelector('[data-bulk-upload-file-list]');
|
|
|
|
if (bulkUploadInput && bulkUploadDropzone && bulkUploadDropzoneInner) {
|
|
const renderBulkUploadFiles = () => {
|
|
const files = Array.from(bulkUploadInput.files || []);
|
|
|
|
if (!bulkUploadSummary || !bulkUploadFileList) {
|
|
return;
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
bulkUploadSummary.classList.add('hidden');
|
|
bulkUploadFileList.classList.add('hidden');
|
|
bulkUploadFileList.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const totalBytes = files.reduce((sum, file) => sum + (file.size || 0), 0);
|
|
const totalMb = (totalBytes / (1024 * 1024)).toFixed(2);
|
|
|
|
bulkUploadSummary.classList.remove('hidden');
|
|
bulkUploadSummary.innerHTML = `<div class="font-semibold">${files.length} file siap dipreview</div><div class="mt-1 text-xs text-emerald-700/80">Total ukuran sementara ${totalMb} MB. Batas upload bulk saat ini maksimal 4 MB per file.</div>`;
|
|
|
|
bulkUploadFileList.classList.remove('hidden');
|
|
bulkUploadFileList.innerHTML = `
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">File terpilih</div>
|
|
<div class="text-xs text-slate-500">Menampilkan ${Math.min(files.length, 8)} dari ${files.length} file</div>
|
|
</div>
|
|
<div class="space-y-2">
|
|
${files.slice(0, 8).map((file) => `
|
|
<div class="flex items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-xs text-slate-600 shadow-sm ring-1 ring-white/70">
|
|
<div class="min-w-0">
|
|
<div class="truncate font-semibold text-slate-800">${file.name}</div>
|
|
<div class="mt-0.5 text-[11px] text-slate-500">${(file.size / 1024 / 1024).toFixed(2)} MB</div>
|
|
</div>
|
|
<span class="inline-flex shrink-0 rounded-full bg-indigo-100 px-2.5 py-1 text-[11px] font-semibold text-indigo-700">${(file.name.split('.').pop() || 'FILE').toUpperCase()}</span>
|
|
</div>
|
|
`).join('')}
|
|
${files.length > 8 ? `<div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-center text-[11px] font-semibold text-slate-500">+${files.length - 8} file lainnya</div>` : ''}
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
['dragenter', 'dragover'].forEach((eventName) => {
|
|
bulkUploadDropzone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
bulkUploadDropzone.classList.add('border-indigo-400', 'bg-indigo-50/40');
|
|
bulkUploadDropzoneInner.classList.add('border-indigo-300', 'bg-indigo-50/50');
|
|
});
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach((eventName) => {
|
|
bulkUploadDropzone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
bulkUploadDropzone.classList.remove('border-indigo-400', 'bg-indigo-50/40');
|
|
bulkUploadDropzoneInner.classList.remove('border-indigo-300', 'bg-indigo-50/50');
|
|
});
|
|
});
|
|
|
|
bulkUploadDropzone.addEventListener('drop', (event) => {
|
|
const files = event.dataTransfer?.files;
|
|
if (files && files.length > 0) {
|
|
bulkUploadInput.files = files;
|
|
renderBulkUploadFiles();
|
|
}
|
|
});
|
|
|
|
bulkUploadInput.addEventListener('change', renderBulkUploadFiles);
|
|
renderBulkUploadFiles();
|
|
}
|
|
|
|
const bulkUploadOverlay = document.querySelector('[data-bulk-upload-overlay]');
|
|
|
|
document.querySelectorAll('[data-bulk-upload-form]').forEach((form) => {
|
|
const submitButton = form.querySelector('[data-bulk-upload-submit-button]');
|
|
const submitLabel = form.querySelector('[data-bulk-upload-submit-label]');
|
|
const submitIcon = form.querySelector('[data-bulk-upload-submit-icon]');
|
|
const mode = form.dataset.bulkUploadMode || 'preview';
|
|
const useAjaxPreview = form.dataset.bulkUploadAjaxPreview === 'true';
|
|
const previewUrl = form.dataset.bulkUploadPreviewUrl || form.getAttribute('action') || '';
|
|
|
|
const showOverlay = (config = {}) => {
|
|
if (!bulkUploadOverlay) {
|
|
return;
|
|
}
|
|
|
|
const title = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-title]');
|
|
const message = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-message]');
|
|
const fileCountLabel = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-file-count]');
|
|
const stageUpload = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-upload]');
|
|
const stageMatch = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-match]');
|
|
const stageSave = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-save]');
|
|
const progressLabel = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-label]');
|
|
const progressPercent = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-percent]');
|
|
const progressBar = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-bar]');
|
|
const fileCountSource = form.dataset.bulkUploadFileCountSource || 'input';
|
|
const inputFileCount = bulkUploadInput?.files?.length || 0;
|
|
const previewFileCount = Number(form.dataset.bulkUploadPreviewCount || 0);
|
|
const activeFileCount = fileCountSource === 'preview' ? previewFileCount : inputFileCount;
|
|
const percent = Math.max(0, Math.min(100, Number(config.percent ?? 0)));
|
|
|
|
if (title) {
|
|
title.textContent = config.title || (mode === 'process'
|
|
? 'Sedang memproses upload permanen...'
|
|
: 'Sedang menyiapkan preview dokumen...');
|
|
}
|
|
|
|
if (message) {
|
|
message.textContent = config.message || (mode === 'process'
|
|
? 'Mohon tunggu. Sistem sedang menyimpan dokumen ke arsip kelulusan dan mencocokkannya ke data siswa. Jangan tutup tab ini agar proses tidak terputus.'
|
|
: 'Mohon tunggu. File sedang diunggah dan dipetakan oleh sistem untuk menyiapkan preview. Jangan tutup tab ini agar proses tidak terputus.');
|
|
}
|
|
|
|
if (fileCountLabel) {
|
|
fileCountLabel.textContent = activeFileCount > 0
|
|
? `${activeFileCount} file sedang diproses`
|
|
: 'Sedang menghitung jumlah file...';
|
|
}
|
|
|
|
if (stageUpload) {
|
|
stageUpload.textContent = config.stageUpload || (mode === 'process' ? 'Validasi file upload' : 'Upload file');
|
|
stageUpload.parentElement?.classList.add('ring-1', 'ring-indigo-300/50', 'bg-indigo-500/10');
|
|
}
|
|
|
|
if (stageMatch) {
|
|
stageMatch.textContent = config.stageMatch || (mode === 'process' ? 'Cocokkan ke siswa lulus' : 'Susun preview & matching');
|
|
stageMatch.parentElement?.classList.add('ring-1', 'ring-sky-300/40', 'bg-sky-500/10');
|
|
}
|
|
|
|
if (stageSave) {
|
|
stageSave.textContent = config.stageSave || (mode === 'process' ? 'Simpan ke arsip kelulusan' : 'Tunggu konfirmasi operator');
|
|
stageSave.parentElement?.classList.add('ring-1', 'ring-emerald-300/40', 'bg-emerald-500/10');
|
|
}
|
|
|
|
if (progressLabel) {
|
|
progressLabel.textContent = config.progressLabel || 'Menunggu proses dimulai...';
|
|
}
|
|
|
|
if (progressPercent) {
|
|
progressPercent.textContent = `${Math.round(percent)}%`;
|
|
}
|
|
|
|
if (progressBar) {
|
|
progressBar.style.width = `${percent}%`;
|
|
progressBar.classList.toggle('animate-pulse', !!config.indeterminate);
|
|
}
|
|
|
|
bulkUploadOverlay.classList.remove('hidden');
|
|
bulkUploadOverlay.classList.add('flex');
|
|
document.body.classList.add('overflow-hidden');
|
|
};
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
if (submitButton) {
|
|
submitButton.disabled = true;
|
|
}
|
|
|
|
if (submitIcon) {
|
|
submitIcon.classList.remove('fa-magnifying-glass', 'fa-upload', 'fa-cloud-arrow-up');
|
|
submitIcon.classList.add('fa-spinner', 'fa-spin');
|
|
}
|
|
|
|
if (submitLabel) {
|
|
submitLabel.textContent = mode === 'process'
|
|
? 'Sedang memproses upload...'
|
|
: 'Sedang menyiapkan preview...';
|
|
}
|
|
|
|
if (mode === 'preview' && useAjaxPreview) {
|
|
event.preventDefault();
|
|
|
|
const formData = new FormData(form);
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
|
|
showOverlay({
|
|
title: 'Sedang mengunggah file preview...',
|
|
message: 'Mohon tunggu. Browser sedang mengirim file ke server untuk disiapkan preview dan pencocokan data.',
|
|
progressLabel: 'Mengunggah file ke server...',
|
|
percent: 0,
|
|
stageUpload: 'Upload file',
|
|
stageMatch: 'Menunggu upload selesai',
|
|
stageSave: 'Siapkan halaman preview',
|
|
indeterminate: false,
|
|
});
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', previewUrl, true);
|
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
if (csrfToken) {
|
|
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
|
|
}
|
|
|
|
xhr.upload.addEventListener('progress', (progressEvent) => {
|
|
if (!progressEvent.lengthComputable) {
|
|
return;
|
|
}
|
|
|
|
const percent = (progressEvent.loaded / progressEvent.total) * 100;
|
|
showOverlay({
|
|
title: 'Sedang mengunggah file preview...',
|
|
message: 'Mohon tunggu. Browser sedang mengirim file ke server untuk disiapkan preview dan pencocokan data.',
|
|
progressLabel: `Upload file ${Math.round(percent)}%`,
|
|
percent,
|
|
stageUpload: 'Upload file',
|
|
stageMatch: 'Menunggu upload selesai',
|
|
stageSave: 'Siapkan halaman preview',
|
|
indeterminate: false,
|
|
});
|
|
});
|
|
|
|
xhr.onreadystatechange = () => {
|
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
|
return;
|
|
}
|
|
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
let response = {};
|
|
|
|
try {
|
|
response = JSON.parse(xhr.responseText || '{}');
|
|
} catch (error) {
|
|
response = {};
|
|
}
|
|
|
|
showOverlay({
|
|
title: 'Upload selesai, sistem sedang menyusun preview...',
|
|
message: 'File sudah terkirim. Sekarang sistem sedang mencocokkan dokumen dan menyiapkan halaman preview.',
|
|
progressLabel: 'Upload selesai, menyiapkan preview...',
|
|
percent: 100,
|
|
stageUpload: 'Upload selesai',
|
|
stageMatch: 'Mencocokkan ke siswa lulus',
|
|
stageSave: 'Membuka halaman preview',
|
|
indeterminate: true,
|
|
});
|
|
|
|
window.location.href = response.redirect_url || window.location.href;
|
|
return;
|
|
}
|
|
|
|
document.body.classList.remove('overflow-hidden');
|
|
bulkUploadOverlay?.classList.add('hidden');
|
|
bulkUploadOverlay?.classList.remove('flex');
|
|
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
|
|
if (submitIcon) {
|
|
submitIcon.classList.remove('fa-spinner', 'fa-spin');
|
|
submitIcon.classList.add('fa-magnifying-glass');
|
|
}
|
|
|
|
if (submitLabel) {
|
|
submitLabel.textContent = 'Preview Mapping Dokumen';
|
|
}
|
|
|
|
let errorMessage = 'Bulk upload gagal diproses. Silakan cek file lalu coba lagi.';
|
|
|
|
try {
|
|
const response = JSON.parse(xhr.responseText || '{}');
|
|
if (response.message) {
|
|
errorMessage = response.message;
|
|
} else if (response.errors) {
|
|
const firstError = Object.values(response.errors).flat()[0];
|
|
if (firstError) {
|
|
errorMessage = firstError;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// noop
|
|
}
|
|
|
|
Swal.fire({
|
|
icon: 'error',
|
|
title: 'Bulk upload gagal',
|
|
text: errorMessage,
|
|
confirmButtonColor: '#dc2626',
|
|
});
|
|
};
|
|
|
|
xhr.send(formData);
|
|
return;
|
|
}
|
|
|
|
showOverlay({
|
|
progressLabel: mode === 'process' ? 'Menyimpan dokumen ke arsip...' : 'Menyiapkan proses...',
|
|
percent: mode === 'process' ? 100 : 15,
|
|
indeterminate: mode === 'process',
|
|
});
|
|
});
|
|
});
|
|
|
|
const graduationFilterForm = document.querySelector('[data-graduation-filter-form]');
|
|
const graduationAcademicYearFilter = document.querySelector('[data-graduation-academic-year-filter]');
|
|
|
|
if (graduationFilterForm && graduationAcademicYearFilter) {
|
|
graduationAcademicYearFilter.addEventListener('change', () => {
|
|
const classFilter = graduationFilterForm.querySelector('[name="class_id"]');
|
|
|
|
if (classFilter) {
|
|
classFilter.value = '';
|
|
}
|
|
|
|
graduationFilterForm.submit();
|
|
});
|
|
}
|
|
|
|
const promotionForm = document.querySelector('[data-student-promotion-form]');
|
|
|
|
if (promotionForm) {
|
|
const rows = promotionForm.querySelectorAll('tbody tr');
|
|
const counterElements = {
|
|
promote: document.querySelector('[data-promotion-counter="promote"]'),
|
|
repeat: document.querySelector('[data-promotion-counter="repeat"]'),
|
|
graduate: document.querySelector('[data-promotion-counter="graduate"]'),
|
|
skip: document.querySelector('[data-promotion-counter="skip"]'),
|
|
};
|
|
const liveSummary = document.querySelector('[data-promotion-live-summary]');
|
|
const capacitySummary = document.querySelector('[data-promotion-capacity-summary]');
|
|
const finalPreview = document.querySelector('[data-promotion-final-preview]');
|
|
const submitButton = document.querySelector('[data-promotion-submit-button]');
|
|
const toolbar = document.querySelector('[data-promotion-toolbar]');
|
|
|
|
const getOptionsForAction = (targetSelect, actionValue) => {
|
|
const optionKey = actionValue === 'repeat' ? 'optionsRepeat' : 'optionsPromote';
|
|
return JSON.parse(targetSelect?.dataset?.[optionKey] || '[]');
|
|
};
|
|
|
|
const calculateCounts = () => {
|
|
const counts = { promote: 0, repeat: 0, graduate: 0, skip: 0 };
|
|
|
|
rows.forEach((row) => {
|
|
const actionSelect = row.querySelector('.promotion-action-select');
|
|
const actionValue = actionSelect?.value || 'skip';
|
|
if (counts[actionValue] !== undefined) {
|
|
counts[actionValue] += 1;
|
|
}
|
|
});
|
|
|
|
return counts;
|
|
};
|
|
|
|
const updateCounters = () => {
|
|
const counts = calculateCounts();
|
|
|
|
Object.entries(counterElements).forEach(([key, element]) => {
|
|
if (element) {
|
|
element.textContent = counts[key];
|
|
}
|
|
});
|
|
|
|
if (liveSummary) {
|
|
liveSummary.innerHTML = `Ringkasan live: <span class="font-semibold">${counts.promote}</span> naik, <span class="font-semibold">${counts.repeat}</span> tinggal kelas, <span class="font-semibold">${counts.graduate}</span> lulus, dan <span class="font-semibold">${counts.skip}</span> dilewati.`;
|
|
}
|
|
};
|
|
|
|
const updateFinalPreview = () => {
|
|
if (!finalPreview) {
|
|
return;
|
|
}
|
|
|
|
const counts = calculateCounts();
|
|
const totalProcessed = counts.promote + counts.repeat + counts.graduate;
|
|
|
|
finalPreview.classList.remove('hidden');
|
|
finalPreview.innerHTML = `
|
|
<div class="font-semibold">Preview final sebelum submit</div>
|
|
<div class="mt-2">Batch ini akan memproses <span class="font-semibold">${totalProcessed}</span> siswa.</div>
|
|
<ul class="mt-2 list-disc space-y-1 pl-5">
|
|
<li><span class="font-semibold">${counts.promote}</span> siswa naik kelas</li>
|
|
<li><span class="font-semibold">${counts.repeat}</span> siswa tinggal kelas</li>
|
|
<li><span class="font-semibold">${counts.graduate}</span> siswa lulus</li>
|
|
<li><span class="font-semibold">${counts.skip}</span> siswa dilewati</li>
|
|
</ul>
|
|
`;
|
|
};
|
|
|
|
const updateCapacitySummary = () => {
|
|
if (!capacitySummary) {
|
|
return;
|
|
}
|
|
|
|
const plannedByClass = new Map();
|
|
|
|
rows.forEach((row) => {
|
|
const actionSelect = row.querySelector('.promotion-action-select');
|
|
const targetSelect = row.querySelector('.promotion-target-select');
|
|
const actionValue = actionSelect?.value || 'skip';
|
|
|
|
if (!targetSelect || !['promote', 'repeat'].includes(actionValue) || !targetSelect.value) {
|
|
return;
|
|
}
|
|
|
|
const options = getOptionsForAction(targetSelect, actionValue);
|
|
const selectedOption = options.find((option) => String(option.id) === String(targetSelect.value));
|
|
|
|
if (!selectedOption) {
|
|
return;
|
|
}
|
|
|
|
const entry = plannedByClass.get(String(selectedOption.id)) || {
|
|
name: selectedOption.name,
|
|
planned: 0,
|
|
remaining: selectedOption.remaining_capacity,
|
|
capacityLabel: selectedOption.capacity_label,
|
|
};
|
|
|
|
entry.planned += 1;
|
|
plannedByClass.set(String(selectedOption.id), entry);
|
|
});
|
|
|
|
const conflicts = Array.from(plannedByClass.values()).filter((entry) => entry.remaining !== null && entry.planned > entry.remaining);
|
|
const warnings = Array.from(plannedByClass.values()).filter((entry) => entry.remaining !== null && entry.planned <= entry.remaining && entry.remaining - entry.planned <= 1);
|
|
|
|
capacitySummary.classList.remove('hidden', 'border-rose-200', 'bg-rose-50', 'text-rose-700', 'border-amber-200', 'bg-amber-50', 'text-amber-700', 'border-emerald-200', 'bg-emerald-50', 'text-emerald-700');
|
|
|
|
if (conflicts.length > 0) {
|
|
capacitySummary.classList.add('border-rose-200', 'bg-rose-50', 'text-rose-700');
|
|
capacitySummary.innerHTML = `<div class="font-semibold">Konflik kapasitas terdeteksi sebelum submit.</div><ul class="mt-2 list-disc space-y-1 pl-5">${conflicts.map((entry) => `<li>${entry.name}: alokasi ${entry.planned} siswa, sisa kuota hanya ${entry.remaining}. (${entry.capacityLabel})</li>`).join('')}</ul>`;
|
|
if (submitButton) {
|
|
submitButton.disabled = true;
|
|
submitButton.classList.add('opacity-50', 'cursor-not-allowed');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (warnings.length > 0) {
|
|
capacitySummary.classList.add('border-amber-200', 'bg-amber-50', 'text-amber-700');
|
|
capacitySummary.innerHTML = `<div class="font-semibold">Perhatian: alokasi mendekati batas kuota.</div><ul class="mt-2 list-disc space-y-1 pl-5">${warnings.map((entry) => `<li>${entry.name}: alokasi ${entry.planned} siswa. ${entry.capacityLabel}.</li>`).join('')}</ul>`;
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
submitButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
}
|
|
return;
|
|
}
|
|
|
|
capacitySummary.classList.add('border-emerald-200', 'bg-emerald-50', 'text-emerald-700');
|
|
capacitySummary.innerHTML = '<div class="font-semibold">Cek kapasitas aman.</div><div class="mt-1">Belum ada konflik kuota pada alokasi kelas tujuan saat ini.</div>';
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
submitButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
}
|
|
};
|
|
|
|
const updateCapacityWarning = (row, actionValue) => {
|
|
const targetSelect = row.querySelector('.promotion-target-select');
|
|
const warningBox = row.querySelector('[data-promotion-capacity-warning]');
|
|
|
|
if (!targetSelect || !warningBox || (actionValue !== 'promote' && actionValue !== 'repeat')) {
|
|
warningBox?.classList.add('hidden');
|
|
warningBox?.classList.remove('border-rose-200', 'bg-rose-50', 'text-rose-700', 'border-amber-200', 'bg-amber-50', 'text-amber-700', 'border-emerald-200', 'bg-emerald-50', 'text-emerald-700');
|
|
return;
|
|
}
|
|
|
|
const options = getOptionsForAction(targetSelect, actionValue);
|
|
const selectedOption = options.find((option) => String(option.id) === String(targetSelect.value));
|
|
|
|
if (!selectedOption) {
|
|
warningBox.classList.add('hidden');
|
|
warningBox.classList.remove('border-rose-200', 'bg-rose-50', 'text-rose-700', 'border-amber-200', 'bg-amber-50', 'text-amber-700', 'border-emerald-200', 'bg-emerald-50', 'text-emerald-700');
|
|
return;
|
|
}
|
|
|
|
warningBox.classList.remove('hidden', 'border-rose-200', 'bg-rose-50', 'text-rose-700', 'border-amber-200', 'bg-amber-50', 'text-amber-700', 'border-emerald-200', 'bg-emerald-50', 'text-emerald-700');
|
|
|
|
if (selectedOption.warning_level === 'full') {
|
|
warningBox.classList.add('border-rose-200', 'bg-rose-50', 'text-rose-700');
|
|
warningBox.textContent = `Peringatan: ${selectedOption.name} sudah penuh. ${selectedOption.capacity_label}.`;
|
|
} else if (selectedOption.warning_level === 'low') {
|
|
warningBox.classList.add('border-amber-200', 'bg-amber-50', 'text-amber-700');
|
|
warningBox.textContent = `Perhatian: kuota ${selectedOption.name} menipis. ${selectedOption.capacity_label}.`;
|
|
} else {
|
|
warningBox.classList.add('border-emerald-200', 'bg-emerald-50', 'text-emerald-700');
|
|
warningBox.textContent = `Kelas tujuan tersedia. ${selectedOption.capacity_label}.`;
|
|
}
|
|
};
|
|
|
|
const renderTargetOptions = (targetSelect, actionValue) => {
|
|
if (!targetSelect) {
|
|
return 0;
|
|
}
|
|
|
|
const selectedKey = actionValue === 'repeat' ? 'selectedRepeat' : 'selectedPromote';
|
|
const options = getOptionsForAction(targetSelect, actionValue);
|
|
const selectedValue = targetSelect.dataset[selectedKey] || '';
|
|
|
|
targetSelect.innerHTML = '<option value="">Pilih kelas tujuan</option>';
|
|
|
|
options.forEach((option) => {
|
|
const optionElement = document.createElement('option');
|
|
optionElement.value = option.id;
|
|
optionElement.textContent = option.option_label || option.name;
|
|
if (String(option.id) === String(selectedValue)) {
|
|
optionElement.selected = true;
|
|
}
|
|
targetSelect.appendChild(optionElement);
|
|
});
|
|
|
|
return options.length;
|
|
};
|
|
|
|
const applyRowActionState = (row, actionValue) => {
|
|
const targetSelect = row.querySelector('.promotion-target-select');
|
|
const targetHelp = row.querySelector('.promotion-target-help');
|
|
const graduatePlaceholder = row.querySelector('.promotion-graduate-placeholder');
|
|
const hiddenTarget = row.querySelector('[data-promotion-target-hidden]');
|
|
|
|
if (targetSelect) {
|
|
const requiresTarget = actionValue === 'promote' || actionValue === 'repeat';
|
|
const optionCount = requiresTarget ? renderTargetOptions(targetSelect, actionValue) : 0;
|
|
targetSelect.disabled = !requiresTarget;
|
|
targetSelect.classList.toggle('bg-slate-100', !requiresTarget);
|
|
targetSelect.classList.toggle('text-slate-400', !requiresTarget);
|
|
targetSelect.classList.toggle('hidden', !requiresTarget);
|
|
|
|
if (!requiresTarget) {
|
|
targetSelect.value = '';
|
|
}
|
|
|
|
if (targetHelp) {
|
|
if (actionValue === 'skip') {
|
|
targetHelp.textContent = 'Siswa ini akan dilewati saat proses promosi.';
|
|
} else if (actionValue === 'graduate') {
|
|
targetHelp.textContent = 'Aksi lulus tidak memerlukan kelas tujuan.';
|
|
} else if (optionCount === 0) {
|
|
targetHelp.textContent = actionValue === 'repeat'
|
|
? 'Belum ada kelas aktif dengan level yang sama pada tahun ajaran target.'
|
|
: 'Belum ada kelas aktif level berikutnya pada tahun ajaran target.';
|
|
} else {
|
|
targetHelp.textContent = actionValue === 'repeat'
|
|
? 'Pilih kelas tujuan dengan level yang sama untuk skenario tinggal kelas.'
|
|
: 'Pilih kelas tujuan level berikutnya untuk skenario naik kelas.';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (graduatePlaceholder) {
|
|
graduatePlaceholder.classList.toggle('hidden', actionValue !== 'graduate');
|
|
graduatePlaceholder.classList.toggle('opacity-60', actionValue === 'skip');
|
|
}
|
|
|
|
if (hiddenTarget && (actionValue === 'graduate' || actionValue === 'skip')) {
|
|
hiddenTarget.value = '';
|
|
}
|
|
|
|
row.classList.toggle('opacity-60', actionValue === 'skip');
|
|
updateCapacityWarning(row, actionValue);
|
|
updateCounters();
|
|
updateCapacitySummary();
|
|
updateFinalPreview();
|
|
};
|
|
|
|
rows.forEach((row) => {
|
|
const actionSelect = row.querySelector('.promotion-action-select');
|
|
const targetSelect = row.querySelector('.promotion-target-select');
|
|
|
|
if (!actionSelect) {
|
|
return;
|
|
}
|
|
|
|
applyRowActionState(row, actionSelect.value);
|
|
actionSelect.addEventListener('change', () => applyRowActionState(row, actionSelect.value));
|
|
targetSelect?.addEventListener('change', () => {
|
|
updateCapacityWarning(row, actionSelect.value);
|
|
updateCapacitySummary();
|
|
});
|
|
});
|
|
|
|
promotionForm.querySelectorAll('[data-promotion-bulk-action]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const actionValue = button.dataset.promotionBulkAction;
|
|
|
|
rows.forEach((row) => {
|
|
const actionSelect = row.querySelector('.promotion-action-select');
|
|
if (!actionSelect) {
|
|
return;
|
|
}
|
|
|
|
if (actionValue === 'promote' && actionSelect.querySelector('option[value="promote"]')) {
|
|
actionSelect.value = 'promote';
|
|
} else if (actionValue === 'graduate' && actionSelect.querySelector('option[value="graduate"]')) {
|
|
actionSelect.value = 'graduate';
|
|
} else if (actionValue === 'repeat') {
|
|
actionSelect.value = 'repeat';
|
|
} else if (actionValue === 'skip') {
|
|
actionSelect.value = 'skip';
|
|
}
|
|
|
|
applyRowActionState(row, actionSelect.value);
|
|
});
|
|
});
|
|
});
|
|
|
|
if (toolbar?.dataset?.classLevel === 'XII') {
|
|
const graduateButton = toolbar.querySelector('[data-promotion-bulk-action="graduate"]');
|
|
graduateButton?.classList.add('ring-2', 'ring-sky-200');
|
|
}
|
|
|
|
updateCounters();
|
|
updateCapacitySummary();
|
|
updateFinalPreview();
|
|
}
|
|
});
|