740 lines
34 KiB
JavaScript
740 lines
34 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';
|
|
|
|
Swal.fire({
|
|
title: 'Hapus data?',
|
|
text: `Yakin ingin menghapus ${entity}? Tindakan ini tidak bisa dibatalkan.`,
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonColor: '#e11d48',
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText: 'Ya, hapus',
|
|
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 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();
|
|
}
|
|
});
|