feat: add graduation archive and public document services

This commit is contained in:
2026-04-14 13:51:39 +00:00
parent d77ad72bcc
commit 58c9b47dc0
30 changed files with 2513 additions and 295 deletions
+189 -3
View File
@@ -369,14 +369,179 @@ document.addEventListener('DOMContentLoaded', () => {
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 optionKey = actionValue === 'repeat' ? 'optionsRepeat' : 'optionsPromote';
const selectedKey = actionValue === 'repeat' ? 'selectedRepeat' : 'selectedPromote';
const options = JSON.parse(targetSelect.dataset[optionKey] || '[]');
const options = getOptionsForAction(targetSelect, actionValue);
const selectedValue = targetSelect.dataset[selectedKey] || '';
targetSelect.innerHTML = '<option value="">Pilih kelas tujuan</option>';
@@ -384,7 +549,7 @@ document.addEventListener('DOMContentLoaded', () => {
options.forEach((option) => {
const optionElement = document.createElement('option');
optionElement.value = option.id;
optionElement.textContent = option.name;
optionElement.textContent = option.option_label || option.name;
if (String(option.id) === String(selectedValue)) {
optionElement.selected = true;
}
@@ -439,16 +604,26 @@ document.addEventListener('DOMContentLoaded', () => {
}
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) => {
@@ -463,6 +638,8 @@ document.addEventListener('DOMContentLoaded', () => {
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') {
@@ -473,5 +650,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
});
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();
}
});