SD YPU

Sistem Presensi & Nilai Siswa

Lupa pin

SD YPU

Sistem Presensi & Nilai Siswa

SD YPU

Sistem Presensi & Nilai Siswa

Informasi Penilaian
Mapel dan jenis penilaian dipilih dari daftar, TP / Materi dan KKM diinput manual.
Daftar Nilai Siswa

SD YPU

Sistem Presensi & Nilai Siswa

Daftar Penilaian
Ketuk salah satu penilaian untuk melihat detail dan mengedit data.
📅 Daftar Hari Libur
Rekap Presensi Bulanan Siswa
Catatan : Data Kehadiran disimpan ke database hanya saat klik tombol simpan.
Kelola Data Siswa
-.
`; } async function printMonthlyReport() { try { const report = await loadMonthlyReportData(); const html = buildReportHtml(report); const iframe = document.createElement("iframe"); iframe.style.position = "fixed"; iframe.style.right = "0"; iframe.style.bottom = "0"; iframe.style.width = "0"; iframe.style.height = "0"; iframe.style.border = "0"; iframe.style.opacity = "0"; iframe.style.pointerEvents = "none"; iframe.srcdoc = html; document.body.appendChild(iframe); const cleanup = () => { setTimeout(() => { try { iframe.remove(); } catch (err) {} }, 1000); }; iframe.onload = () => { setTimeout(() => { try { iframe.contentWindow?.focus(); iframe.contentWindow?.print(); } catch (err) { console.error(err); showError(new Error("Gagal membuka dialog cetak.")); } finally { cleanup(); } }, 500); }; } catch (error) { showError(error); } } function styleCell(cell, style) { cell.s = style; return cell; } function createReportWorkbook(report) { if (!window.XLSX) { throw new Error("Library Excel belum dimuat."); } const XLSX = window.XLSX; const wb = XLSX.utils.book_new(); const days = report.days || []; const holidaySet = new Set((report.holidays || []).map(h => h.date)); const students = report.students || []; const monthLabel = report.monthLabel || ""; const classRoom = getReportRoomName(report?.class?.code || ""); const teacherName = getReportTeacherName(report); const header1 = ["DAFTAR HADIR SISWA SD YPU"]; const header2 = [`TAHUN PELAJARAN ${schoolYearLabel()}`]; const header3 = [`Jenis Rombel: Kelas Utama - Nama Rombel: ${classRoom} - ${semesterLabel()} - Wali Kelas: ${teacherName}`]; const row5 = ["NOMOR", "", "NAMA SISWA", "L/P", ...days.map(() => ""), "Hadir", "Sakit", "Ijin", "Alpha"]; const row6 = ["", "", "", "", "Tanggal", ...days.map(() => ""), "", "", "", ""]; const row7 = ["URUT", "NISN", "", "", ...days.map((d) => String(d.day)), "", "", "", ""]; const data = [header1, header2, header3, [], row5, row6, row7]; students.sort((a, b) => (a.name || "").localeCompare(b.name || "", "id") ); students.forEach((student, index) => { const totals = student.totals || { H: 0, S: 0, I: 0, A: 0 }; const row = [ index + 1, student.nisn || "", student.name || "", student.gender || "L", ...days.map((day) => { const holidayText = getHolidayText(day, report); return holidayText ? holidayText : (student.attendance?.[day.date] || ""); }), totals.H > 0 ? totals.H : "", totals.S > 0 ? totals.S : "", totals.I > 0 ? totals.I : "", totals.A > 0 ? totals.A : "", ]; data.push(row); }); const bodyStartRow = 7; const footerDateRow = bodyStartRow + students.length + 2; // 2 baris kosong setelah data siswa const footerNameRow = footerDateRow + 5; // ruang tanda tangan while (data.length <= footerNameRow) { data.push([]); } const ws = XLSX.utils.aoa_to_sheet(data); const studentStartRow = bodyStartRow; days.forEach((day, dayIdx) => { if (!holidaySet.has(day.date) && !day.isHoliday) return; const col = 4 + dayIdx; for (let r = studentStartRow; r < studentStartRow + students.length; r++) { const addr = XLSX.utils.encode_cell({ r, c: col }); if (!ws[addr]) continue; ws[addr].s = { fill: { patternType: "solid", fgColor: { rgb: "FEE2E2" } }, font: { color: { rgb: "B91C1C" }, bold: true }, alignment: { horizontal: "center", vertical: "center" }, }; } }); const colWidths = [ 8, 12, 30, 6, ...days.map(() => 4.0), 5, 5, 5, 5, ]; ws["!cols"] = colWidths.map((width) => ({ wch: width })); const lastCol = 7 + days.length; const merges = [ { s: { r: 0, c: 0 }, e: { r: 0, c: lastCol } }, { s: { r: 1, c: 0 }, e: { r: 1, c: lastCol } }, { s: { r: 2, c: 0 }, e: { r: 2, c: lastCol } }, { s: { r: 4, c: 0 }, e: { r: 5, c: 1 } }, { s: { r: 4, c: 2 }, e: { r: 6, c: 2 } }, { s: { r: 4, c: 3 }, e: { r: 6, c: 3 } }, { s: { r: 4, c: 4 }, e: { r: 4, c: 3 + days.length } }, { s: { r: 5, c: 4 }, e: { r: 5, c: 3 + days.length } }, { s: { r: 4, c: 4 + days.length }, e: { r: 6, c: 4 + days.length } }, { s: { r: 4, c: 5 + days.length }, e: { r: 6, c: 5 + days.length } }, { s: { r: 4, c: 6 + days.length }, e: { r: 6, c: 6 + days.length } }, { s: { r: 4, c: 7 + days.length }, e: { r: 6, c: 7 + days.length } }, ]; ws["!merges"] = merges; const thinBorder = { top: { style: "thin", color: { rgb: "000000" } }, bottom: { style: "thin", color: { rgb: "000000" } }, left: { style: "thin", color: { rgb: "000000" } }, right: { style: "thin", color: { rgb: "000000" } }, }; const styleTitle = { font: { bold: true, sz: 14 }, alignment: { horizontal: "center", vertical: "center" }, }; const styleHeader = { font: { bold: true }, alignment: { horizontal: "center", vertical: "center", wrapText: true }, fill: { patternType: "solid", fgColor: { rgb: "F2F2F2" } }, border: thinBorder, }; const styleBody = { alignment: { horizontal: "center", vertical: "center", wrapText: false }, border: thinBorder, }; const styleHoliday = { font: { bold: true }, fill: { patternType: "solid", fgColor: { rgb: "FECACA" } }, alignment: { horizontal: "center", vertical: "center", wrapText: true }, border: thinBorder }; const styleLeft = { ...styleBody, alignment: { horizontal: "left", vertical: "center", wrapText: false, shrinkToFit: true }, }; const styleFooter = { alignment: { horizontal: "right", vertical: "center", wrapText: false }, }; const styleFooterName = { font: { bold: true }, alignment: { horizontal: "right", vertical: "center", wrapText: false }, }; const statusStyle = { H: { font: { bold: true, color: { rgb: "000000" } }, alignment: { horizontal: "center", vertical: "center" }, border: thinBorder }, S: { font: { bold: true, color: { rgb: "D97706" } }, alignment: { horizontal: "center", vertical: "center" }, border: thinBorder }, I: { font: { bold: true, color: { rgb: "2563EB" } }, alignment: { horizontal: "center", vertical: "center" }, border: thinBorder }, A: { font: { bold: true, color: { rgb: "DC2626" } }, alignment: { horizontal: "center", vertical: "center" }, border: thinBorder }, }; function setCell(addr, value, style) { ws[addr] = styleCell({ t: typeof value === "number" ? "n" : "s", v: value }, style); } function ensureBorder(addr, style = styleBody) { if (!ws[addr]) { ws[addr] = { t: "s", v: "" }; } ws[addr].s = { ...(ws[addr].s || {}), ...style }; } // Titles setCell("A1", header1[0], styleTitle); setCell("A2", header2[0], styleTitle); setCell("A3", header3[0], styleTitle); // Header rows setCell("A5", row5[0], styleHeader); setCell("A7", "URUT", styleHeader); setCell("B7", "NISN", styleHeader); setCell("C5", row5[2], styleHeader); setCell("D5", row5[3], styleHeader); setCell("E5", `Bulan ${monthLabel}`, styleHeader); setCell(XLSX.utils.encode_cell({ r: 4, c: 4 + days.length }), "Hadir", styleHeader); setCell(XLSX.utils.encode_cell({ r: 4, c: 5 + days.length }), "Sakit", styleHeader); setCell(XLSX.utils.encode_cell({ r: 4, c: 6 + days.length }), "Ijin", styleHeader); setCell(XLSX.utils.encode_cell({ r: 4, c: 7 + days.length }), "Alpha", styleHeader); setCell("A6", row6[0], styleHeader); setCell("B6", row6[1], styleHeader); setCell("E6", row6[4], styleHeader); for (let i = 0; i < days.length; i += 1) { const addr = XLSX.utils.encode_cell({ r: 6, c: 4 + i }); setCell(addr, String(days[i].day), styleHeader); } // Body students.forEach((student, idx) => { const rowNumber = bodyStartRow + idx; const values = [ idx + 1, student.nisn || "", student.name || "", student.gender || "L", ...days.map((day) => student.attendance?.[day.date] || ""), (student.totals?.H || 0) > 0 ? student.totals.H : "", (student.totals?.S || 0) > 0 ? student.totals.S : "", (student.totals?.I || 0) > 0 ? student.totals.I : "", (student.totals?.A || 0) > 0 ? student.totals.A : "", ]; values.forEach((value, c) => { const addr = XLSX.utils.encode_cell({ r: rowNumber, c }); const rawStatus = c >= 4 && c < 4 + days.length ? String(value || "").toUpperCase() : ""; const style = (c === 1 || c === 2) ? styleLeft : styleBody; if (c >= 4 && c < 4 + days.length) { const day = days[c - 4]; if (day && (isWeekendDate(day.date) || day.isHoliday || holidaySet.has(day.date))) { setCell(addr, "", styleHoliday); } else { const statusStyleApplied = statusStyle[rawStatus] || styleBody; setCell(addr, statusLabel(rawStatus), statusStyleApplied); } } }); }); // Border grid for the full table area so the lines are complete. for (let r = 4; r < bodyStartRow + students.length; r += 1) { for (let c = 0; c <= lastCol; c += 1) { const addr = XLSX.utils.encode_cell({ r, c }); if (!ws[addr]) { ws[addr] = { t: "s", v: "" }; } ws[addr].s = { ...(ws[addr].s || {}), border: thinBorder, }; } } // Footer const signStartCol = Math.max(0, lastCol - 6); // Pastikan nilai tanda tangan ikut masuk ke file Excel, bukan hanya style/merge. data[footerDateRow][signStartCol] = `Bandung, ${monthLabel}`; data[footerDateRow + 1][signStartCol] = "Kepala Sekolah"; data[footerNameRow][signStartCol] = HEADMASTER_NAME; ws["!merges"].push( { s: { r: footerDateRow, c: signStartCol }, e: { r: footerDateRow, c: lastCol } }, { s: { r: footerDateRow + 1, c: signStartCol }, e: { r: footerDateRow + 1, c: lastCol } }, { s: { r: footerDateRow + 2, c: signStartCol }, e: { r: footerDateRow + 2, c: lastCol } }, { s: { r: footerDateRow + 3, c: signStartCol }, e: { r: footerDateRow + 3, c: lastCol } }, { s: { r: footerDateRow + 4, c: signStartCol }, e: { r: footerDateRow + 4, c: lastCol } }, { s: { r: footerNameRow, c: signStartCol }, e: { r: footerNameRow, c: lastCol } } ); setCell( XLSX.utils.encode_cell({ r: footerDateRow, c: signStartCol }), `Bandung, ${monthLabel}`, { font: { italic: true }, alignment: { horizontal: "center", vertical: "center", wrapText: false } } ); setCell( XLSX.utils.encode_cell({ r: footerDateRow + 1, c: signStartCol }), "Kepala Sekolah", { font: { bold: true }, alignment: { horizontal: "center", vertical: "center", wrapText: false } } ); setCell( XLSX.utils.encode_cell({ r: footerNameRow, c: signStartCol }), HEADMASTER_NAME, { font: { bold: true }, alignment: { horizontal: "center", vertical: "center", wrapText: false } } ); const range = XLSX.utils.decode_range(ws["!ref"]); range.e.r = Math.max(range.e.r, footerNameRow); range.e.c = Math.max(range.e.c, lastCol); ws["!ref"] = XLSX.utils.encode_range(range); // Row heights ws["!rows"] = [ { hpt: 18 }, { hpt: 18 }, { hpt: 18 }, { hpt: 8 }, { hpt: 20 }, { hpt: 20 }, { hpt: 20 }, ]; for (let i = 0; i < students.length; i += 1) { ws["!rows"].push({ hpt: 18 }); } const totalRowsNeeded = footerNameRow + 1; while (ws["!rows"].length < totalRowsNeeded) { ws["!rows"].push({ hpt: 18 }); } ws["!rows"][footerDateRow] = { hpt: 18 }; for (let i = footerDateRow + 1; i < footerNameRow; i += 1) { ws["!rows"][i] = { hpt: 18 }; } ws["!rows"][footerNameRow] = { hpt: 18 }; XLSX.utils.book_append_sheet(wb, ws, "Laporan"); return wb; } async function exportMonthlyReportExcel() { try { const report = await loadMonthlyReportData(); const wb = createReportWorkbook(report); const filename = `laporan-Presensi-${report.class.code}-${String(report.month).padStart(2, "0")}-${report.year}.xlsx`; window.XLSX.writeFile(wb, filename, { compression: true }); } catch (error) { showError(error); } } function statusLetterForExcel(status) { if (status === "H") return "✓"; if (status === "S") return "S"; if (status === "I") return "I"; if (status === "A") return "A"; return ""; } async function confirmDiscardIfDirty() { if (!state.dirtyAttendance) return true; return confirm("Ada perubahan Presensi yang belum disimpan. Lanjutkan dan buang perubahan ini?"); } async function changeClass(nextClassCode) { if (!isClassAllowedForLogin(nextClassCode)) { setToast('Kelas tidak sesuai dengan akun login'); el("#classSelect").value = state.classCode; return; } if (nextClassCode === state.classCode) return; if (!(await confirmDiscardIfDirty())) { el("#classSelect").value = state.classCode; return; } state.classCode = nextClassCode; reportState.classCode = nextClassCode; assessmentState.meta.classCode = nextClassCode; localStorage.setItem("Presensi-class-code", state.classCode); localStorage.setItem("Presensi-report-class", reportState.classCode); el("#classSelect").value = state.classCode; const rosterClassSelect = el("#rosterClassSelect"); if (rosterClassSelect) rosterClassSelect.value = state.classCode; syncClassSelectors(); await loadClassData(); } async function changeDate(nextDate) { if (nextDate === state.date) return; if (!(await confirmDiscardIfDirty())) return; state.date = nextDate; await loadClassData(); } function openSheet() { el("#overlay").style.display = "flex"; refreshPopupScrollLock(); setBackPanelState("sheet"); } function closeSheet(fromBack = false) { if (!fromBack && isAppPanelState()) { history.back(); return; } el("#overlay").style.display = "none"; refreshPopupScrollLock(); } function closeRoster(fromBack = false) { if (!fromBack && isAppPanelState()) { history.back(); return; } el("#rosterOverlay").style.display = "none"; hideAddStudentForm(true); refreshPopupScrollLock(); } function openRoster() { closeSheet(true); el("#rosterOverlay").style.display = "flex"; refreshPopupScrollLock(); setBackPanelState("roster", "replace"); loadRosterEditor().catch(showError); } function normalizeGender(value) { return String(value || "L").trim().toUpperCase().startsWith("P") ? "P" : "L"; } function rosterRowTemplate(row = {}, idx = 0) { const id = row.id || ""; const nisn = row.nisn || ""; const name = row.name || ""; const gender = normalizeGender(row.gender); const active = row.active !== false && row.active !== 0 && row.active !== "0"; return `
${idx + 1}
${escapeHtml(name)}
NISN ${escapeHtml(nisn || "-")} • ${gender}
`; } async function loadRosterEditor() { const data = await apiGet({ action: "students", classCode: state.classCode, includeInactive: "1" }); state.rosterRows = (data.students || []).map((row, idx) => ({ id: row.id, nisn: row.nisn || "", name: row.name || "", gender: normalizeGender(row.gender), active: row.active ? 1 : 0, studentOrder: row.studentOrder || idx + 1, })); state.dirtyRoster = false; renderRoster(); } function renderRoster() { const list = state.rosterRows || []; const activeCount = list.filter(row => row.active).length; const inactiveCount = list.length - activeCount; const className = classLabel(state.classCode); const classLabelEl = el("#rosterClassLabel"); if (classLabelEl) classLabelEl.textContent = className; el("#rosterHint").innerHTML = `Jumlah siswa: ${list.length} • Aktif: ${activeCount} • Nonaktif: ${inactiveCount}.`; el("#rosterList").innerHTML = list.length ? list.map((row, idx) => rosterRowTemplate(row, idx)).join("") : `
Belum ada siswa di ${className}. Klik + Tambah untuk menambahkan siswa baru.
`; el("#rosterList").querySelectorAll(".student-active-toggle").forEach(input => { input.addEventListener("change", async () => { const index = Number(input.dataset.index); const row = state.rosterRows[index]; if (!row) return; const previous = row.active; row.active = input.checked ? 1 : 0; try { await saveRoster({ silent: true }); await loadClassData(); renderRoster(); setToast(`${row.name} ${row.active ? "diaktifkan" : "dinonaktifkan"}`); } catch (error) { row.active = previous; input.checked = Boolean(previous); showError(error); renderRoster(); } }); }); } function collectRosterRows() { return (state.rosterRows || []) .map((row) => ({ id: row.id ? Number(row.id) : null, nisn: String(row.nisn || "").trim(), gender: normalizeGender(row.gender), name: String(row.name || "").trim(), active: row.active ? 1 : 0, })) .filter(row => row.name || row.id); } async function saveRoster(options = {}) { const rows = collectRosterRows(); await apiPost("saveRoster", { classCode: state.classCode, students: rows, }); state.dirtyRoster = false; if (!options.silent) { setToast(`Data siswa ${classLabel(state.classCode)} tersimpan`); } } function showAddStudentForm() { el("#addStudentPanel").style.display = "block"; setBackPanelState("addStudent"); el("#newStudentNisn").value = ""; el("#newStudentName").value = ""; el("#newStudentGender").value = ""; setTimeout(() => el("#newStudentName")?.focus(), 50); } function hideAddStudentForm(fromBack = false) { if (!fromBack && isAppPanelState() && history.state?.[APP_PANEL_STATE_KEY] === "addStudent") { history.back(); return; } const panel = el("#addStudentPanel"); if (panel) panel.style.display = "none"; } async function addStudentFromForm() { const nisn = el("#newStudentNisn").value.trim(); const name = el("#newStudentName").value.trim(); const gender = el("#newStudentGender").value; if (!name) { setToast("Nama siswa wajib diisi"); return; } if (!gender) { setToast("Jenis kelamin wajib dipilih"); return; } state.rosterRows.push({ id: null, nisn, name: name.toUpperCase(), gender: normalizeGender(gender), active: 1, }); try { await saveRoster({ silent: true }); hideAddStudentForm(); await loadRosterEditor(); await loadClassData(); setToast("Siswa baru ditambahkan"); } catch (error) { state.rosterRows.pop(); showError(error); } } async function moveRoster() { setToast("Fitur pindah kelas tidak digunakan pada kelola data siswa ini."); } async function deleteClassRoster() { setToast("Hapus kelas tidak digunakan pada kelola data siswa ini."); } function showError(error) { console.error(error); setToast(error.message || "Terjadi kesalahan"); } function syncMenuWelcome() { const node = el("#menuWelcomeRole"); if (node) node.textContent = accountLabel(); } function syncAssessmentClassSelectors() { const allowed = allowedClassesForLogin(); const current = allowed.includes(state.classCode) ? state.classCode : allowed[0]; const rows = state.classes.filter(item => allowed.includes(item.code)); const options = rows.map(item => ``).join(''); const assessmentClassSelect = el("#assessmentClassSelect"); if (assessmentClassSelect) assessmentClassSelect.innerHTML = options; const historyClassSelect = el("#historyClassSelect"); if (historyClassSelect) historyClassSelect.innerHTML = options; } function buildAssessmentTypeOptions(selectEl, current = "") { if (!selectEl) return; const options = [''].concat(ASSESSMENT_TYPES.map(type => ``)); if (selectEl.id === 'assessmentTypeSelect') { selectEl.innerHTML = [''].concat(ASSESSMENT_TYPES.map(type => ``)).join(''); } else { selectEl.innerHTML = options.join(''); } } function buildSubjectOptions(selectEl, current = "", includeAll = false) { if (!selectEl) return; const account = getSpecialAccount(); const items = assessmentState.subjects || []; const prefix = account ? [] : (includeAll ? [''] : ['']); selectEl.innerHTML = prefix.concat(items.map(item => ``)).join(''); if (account && items[0]) selectEl.value = items[0].name; } async function loadAssessmentSubjects() { const data = await apiGet({ action: 'subjects', classCode: assessmentState.meta.classCode || state.classCode }); assessmentState.subjects = filterSubjectsForAccount(data.subjects || []); const account = getSpecialAccount(); if (account) { assessmentState.meta.subjectName = account.subject; assessmentState.historySubject = account.subject; } else { if (isWaliBlockedSubject(assessmentState.meta.subjectName)) assessmentState.meta.subjectName = ''; if (isWaliBlockedSubject(assessmentState.historySubject)) assessmentState.historySubject = ''; } buildSubjectOptions(el('#assessmentSubjectSelect'), assessmentState.meta.subjectName || '', false); buildSubjectOptions(el('#historySubjectSelect'), assessmentState.historySubject, true); buildAssessmentTypeOptions(el('#assessmentTypeSelect'), assessmentState.meta.assessmentType || ''); buildAssessmentTypeOptions(el('#historyTypeSelect'), assessmentState.historyType || ''); } function renderAssessmentSummary() { const meta = assessmentState.meta; const grid = el('#nilaiInfoGrid'); if (!grid) return; const items = [ ['Kelas', classLabel(meta.classCode || state.classCode)], ['Tanggal', meta.date ? prettyDate(parseLocalDate(meta.date)) : '-'], ['Mapel', meta.subjectName || '-'], ['Jenis Penilaian', meta.assessmentType || '-'], ['TP / Materi', meta.title || '-'], ['KKM', meta.kkm ? String(meta.kkm) : '-'], ]; grid.innerHTML = items.map(([label, value]) => `
${label}
${escapeHtml(value || '-')}
`).join(''); } function assessmentIsReady() { const meta = assessmentState.meta || {}; return Boolean((meta.classCode || state.classCode) && meta.date && meta.subjectName && meta.assessmentType && meta.title && String(meta.kkm ?? '').trim() !== ''); } function assessmentScoreStatus(score) { if (score === '' || score === null || score === undefined || Number.isNaN(Number(score))) { return { cls: 'empty', label: 'Kosong' }; } const num = Number(score); return num >= Number(assessmentState.meta.kkm || 75) ? { cls: 'pass', label: 'Tuntas' } : { cls: 'fail', label: 'Belum' }; } function getAssessmentScore(studentId) { return assessmentState.scores[String(studentId)] || { score: '', note: '' }; } function setAssessmentScore(studentId, score, note = null) { const key = String(studentId); const prev = getAssessmentScore(studentId); assessmentState.scores[key] = { score, note: note === null ? (prev.note || '') : note }; } function renderAssessmentStudents() { const wrap = el('#nilaiStudentList'); if (!wrap) return; const students = state.students || []; const ready = assessmentIsReady(); document.body.classList.toggle('assessment-ready', ready); const saveNilaiButton = el('#saveNilaiBtn'); if (saveNilaiButton) saveNilaiButton.disabled = !ready; if (!students.length) { wrap.innerHTML = '
Belum ada siswa aktif di kelas ini.
'; return; } const helper = ready ? '' : '
Klik Penilaian Baru terlebih dahulu agar daftar siswa aktif dan bisa diisi.
'; wrap.innerHTML = helper + students.map((student, idx) => { const rec = getAssessmentScore(student.id); const status = assessmentScoreStatus(rec.score); return `
${idx + 1}
${escapeHtml(student.name || '')}
NISN ${escapeHtml(student.nisn || '-')} • ${student.gender === 'P' ? 'Perempuan' : 'Laki-laki'}
${status.label}
`; }).join(''); wrap.querySelectorAll('[data-score-input]').forEach(input => { input.addEventListener('click', e => { e.stopPropagation(); if (!assessmentIsReady()) return setToast('Buat penilaian baru terlebih dahulu'); }); input.addEventListener('input', () => { if (!assessmentIsReady()) { input.value = ''; return setToast('Buat penilaian baru terlebih dahulu'); } let raw = input.value; if (raw !== '') { let num = Number(raw); if (Number.isFinite(num)) { if (num < 0) num = 0; if (num > 100) num = 100; raw = String(num); input.value = raw; } } setAssessmentScore(input.dataset.scoreInput, raw === '' ? '' : Number(raw)); const row = input.closest('.nilai-row'); const badge = row?.querySelector('.score-badge'); if (badge) { const status = assessmentScoreStatus(raw === '' ? '' : Number(raw)); badge.className = `score-badge ${status.cls}`; badge.textContent = status.label; } }); input.addEventListener('dblclick', e => { e.stopPropagation(); if (!assessmentIsReady()) return setToast('Buat penilaian baru terlebih dahulu'); openScoreDetail(input.dataset.scoreInput); }); }); wrap.querySelectorAll('[data-score-badge]').forEach(badge => { badge.addEventListener('click', e => { e.stopPropagation(); if (!assessmentIsReady()) return setToast('Buat penilaian baru terlebih dahulu'); openScoreDetail(badge.dataset.scoreBadge); }); }); wrap.querySelectorAll('[data-student-row]').forEach(row => { row.addEventListener('click', () => { if (!assessmentIsReady()) return setToast('Buat penilaian baru terlebih dahulu'); openScoreDetail(row.dataset.studentRow); }); }); } function resetAssessmentDraft(keepMeta = false) { assessmentState.currentSessionId = null; assessmentState.scores = {}; if (!keepMeta) { assessmentState.meta = { classCode: state.classCode, date: todayIso(), subjectName: '', assessmentType: '', title: '', kkm: '', }; } renderAssessmentSummary(); renderAssessmentStudents(); } function openAssessmentSetup() { const account = getSpecialAccount(); const allowed = allowedClassesForLogin(); const currentClass = allowed.includes(assessmentState.meta.classCode) ? assessmentState.meta.classCode : (allowed.includes(state.classCode) ? state.classCode : allowed[0]); assessmentState.meta.classCode = currentClass; syncAssessmentClassSelectors(); const assessmentClassSelect = el('#assessmentClassSelect'); if (assessmentClassSelect) { assessmentClassSelect.value = currentClass; assessmentClassSelect.disabled = !account; } if (account) assessmentState.meta.subjectName = account.subject; buildSubjectOptions(el('#assessmentSubjectSelect'), assessmentState.meta.subjectName, false); const subjectSelect = el('#assessmentSubjectSelect'); if (subjectSelect) subjectSelect.disabled = Boolean(account); buildAssessmentTypeOptions(el('#assessmentTypeSelect'), assessmentState.meta.assessmentType || ''); el('#assessmentDateInput').value = assessmentState.meta.date || todayIso(); el('#assessmentTitleInput').value = assessmentState.meta.title || ''; el('#assessmentKkmInput').value = assessmentState.meta.kkm || 75; el('#assessmentSetupOverlay').style.display = 'flex'; refreshPopupScrollLock(); setBackPanelState("assessmentSetup"); } function closeAssessmentSetup(fromBack = false) { if (!fromBack && isAppPanelState() && history.state?.[APP_PANEL_STATE_KEY] === "assessmentSetup") { history.back(); return; } el('#assessmentSetupOverlay').style.display = 'none'; refreshPopupScrollLock(); } function openNilaiOptions() { el('#nilaiOptionsOverlay').style.display = 'flex'; refreshPopupScrollLock(); setBackPanelState("nilaiOptions"); } function closeNilaiOptions(fromBack = false) { if (!fromBack && isAppPanelState() && history.state?.[APP_PANEL_STATE_KEY] === "nilaiOptions") { history.back(); return; } el('#nilaiOptionsOverlay').style.display = 'none'; refreshPopupScrollLock(); } async function saveAssessmentSetup() { const account = getSpecialAccount(); const classCode = el('#assessmentClassSelect').value || state.classCode; if (!isClassAllowedForLogin(classCode)) return setToast('Kelas tidak sesuai dengan akun login'); const subjectName = account ? account.subject : el('#assessmentSubjectSelect').value; const assessmentType = el('#assessmentTypeSelect').value; const date = el('#assessmentDateInput').value; const title = el('#assessmentTitleInput').value.trim(); const kkm = Number(el('#assessmentKkmInput').value || 75); if (!subjectName) return setToast('Pilih mapel terlebih dahulu'); if (!assessmentType) return setToast('Pilih jenis penilaian'); if (!date) return setToast('Tanggal penilaian wajib diisi'); if (!title) return setToast('TP / Materi wajib diisi'); if (classCode !== state.classCode) { state.classCode = classCode; reportState.classCode = classCode; localStorage.setItem('Presensi-class-code', state.classCode); localStorage.setItem('Presensi-report-class', reportState.classCode); syncClassSelectors(); await loadClassData(); } assessmentState.meta = { classCode, subjectName, assessmentType, date, title, kkm: Number.isFinite(kkm) ? kkm : 75 }; assessmentState.currentSessionId = null; assessmentState.scores = {}; closeAssessmentSetup(); renderAssessmentSummary(); renderAssessmentStudents(); setToast('Variabel penilaian disiapkan'); } function renderScoreOptionGrid(selectedScore = '') { const holder = el('#scoreOptionGrid'); if (!holder) return; const options = [40, 50, 60, 70, 75, 80, 85, 90, 95, 100]; holder.innerHTML = options.map(score => ``).join(''); holder.querySelectorAll('[data-score-choice]').forEach(btn => { btn.addEventListener('click', () => { holder.querySelectorAll('[data-score-choice]').forEach(x => x.classList.remove('active')); btn.classList.add('active'); el('#scoreDetailInput').value = btn.dataset.scoreChoice; }); }); const manual = el('#scoreDetailInput'); if (manual) { manual.oninput = () => { holder.querySelectorAll('[data-score-choice]').forEach(x => { x.classList.toggle('active', x.dataset.scoreChoice === manual.value); }); }; } } function openScoreDetail(studentId) { if (!assessmentIsReady()) { setToast('Buat penilaian baru terlebih dahulu'); return; } assessmentState.selectedStudentId = String(studentId); const student = state.students.find(item => String(item.id) === String(studentId)); if (!student) return; const rec = getAssessmentScore(studentId); el('#scoreDetailStudentName').textContent = student.name || 'Detail Nilai'; el('#scoreDetailStudentMeta').textContent = `NISN ${student.nisn || '-'} • ${student.gender === 'P' ? 'Perempuan' : 'Laki-laki'}`; el('#scoreDetailInput').value = rec.score ?? ''; renderScoreOptionGrid(rec.score ?? ''); el('#scoreDetailNote').value = rec.note || ''; el('#scoreDetailOverlay').style.display = 'flex'; refreshPopupScrollLock(); setBackPanelState("scoreDetail"); } function closeScoreDetail(fromBack = false) { if (!fromBack && isAppPanelState() && history.state?.[APP_PANEL_STATE_KEY] === "scoreDetail") { history.back(); return; } el('#scoreDetailOverlay').style.display = 'none'; refreshPopupScrollLock(); } function saveScoreDetail() { const studentId = assessmentState.selectedStudentId; if (!studentId) return; const raw = String(el('#scoreDetailInput').value || '').trim(); const value = raw === '' ? '' : Math.max(0, Math.min(100, Number(raw))); setAssessmentScore(studentId, value, el('#scoreDetailNote').value.trim()); closeScoreDetail(); renderAssessmentStudents(); } function clearScoreDetail() { const studentId = assessmentState.selectedStudentId; if (!studentId) return; setAssessmentScore(studentId, '', ''); closeScoreDetail(); renderAssessmentStudents(); } async function saveAssessmentSession() { if (!assessmentIsReady()) { setToast('Lengkapi variabel penilaian dahulu'); openAssessmentSetup(); return; } const meta = assessmentState.meta; const account = getSpecialAccount(); if (account) meta.subjectName = account.subject; if (!isClassAllowedForLogin(meta.classCode || state.classCode)) { setToast('Kelas tidak sesuai dengan akun login'); return; } if (!account && isWaliBlockedSubject(meta.subjectName)) { setToast('Mapel ini tidak tersedia untuk akun wali kelas'); return; } if (!meta.subjectName || !meta.assessmentType || !meta.title || !meta.date) { setToast('Lengkapi variabel penilaian dahulu'); openAssessmentSetup(); return; } const scores = (state.students || []).map(student => { const rec = getAssessmentScore(student.id); return { studentId: student.id, score: rec.score === '' ? null : rec.score, note: rec.note || '' }; }); try { const result = await apiPost('saveAssessmentSession', { sessionId: assessmentState.currentSessionId, classCode: meta.classCode || state.classCode, date: meta.date, subjectName: meta.subjectName, assessmentType: meta.assessmentType, title: meta.title, kkm: meta.kkm, scores, }); assessmentState.currentSessionId = result.sessionId; setToast('Nilai berhasil disimpan'); resetAssessmentDraft(false); } catch (error) { showError(error); } } async function openPenilaianModule() { if (!state.students.length) { await loadClassData(); } assessmentState.meta.classCode = state.classCode; assessmentState.meta.date = todayIso(); assessmentState.meta.subjectName = ''; await loadAssessmentSubjects(); resetAssessmentDraft(false); showPenilaianPage(); } function buildHistoryMonthOptions() { buildMonthOptions(el('#historyMonthSelect'), assessmentState.historyMonth); syncAssessmentClassSelectors(); const historyClassSelect = el('#historyClassSelect'); if (historyClassSelect) historyClassSelect.value = isClassAllowedForLogin(state.classCode) ? state.classCode : allowedClassesForLogin()[0]; const account = getSpecialAccount(); if (account) assessmentState.historySubject = account.subject; buildSubjectOptions(el('#historySubjectSelect'), assessmentState.historySubject, true); const historySubjectSelect = el('#historySubjectSelect'); if (historySubjectSelect) historySubjectSelect.disabled = Boolean(account); buildAssessmentTypeOptions(el('#historyTypeSelect'), assessmentState.historyType || ''); } async function loadAssessmentHistory() { const account = getSpecialAccount(); const selectedClass = el('#historyClassSelect')?.value || state.classCode; const classCode = isClassAllowedForLogin(selectedClass) ? selectedClass : allowedClassesForLogin()[0]; assessmentState.historyMonth = Number(el('#historyMonthSelect')?.value || assessmentState.historyMonth); assessmentState.historySubject = account ? account.subject : (el('#historySubjectSelect')?.value || ''); assessmentState.historyType = el('#historyTypeSelect')?.value || ''; localStorage.setItem('Penilaian-history-month', String(assessmentState.historyMonth)); localStorage.setItem('Penilaian-history-subject', assessmentState.historySubject); localStorage.setItem('Penilaian-history-type', assessmentState.historyType); const data = await apiGet({ action: 'assessmentSessions', classCode, month: assessmentState.historyMonth, year: assessmentState.historyYear, subjectName: assessmentState.historySubject, assessmentType: assessmentState.historyType, }); renderAssessmentHistory(filterSessionsForAccount(data.sessions || [])); } function renderAssessmentHistory(items) { const wrap = el('#assessmentHistoryList'); if (!wrap) return; if (!items.length) { wrap.innerHTML = '
Belum ada riwayat penilaian pada filter ini.
'; return; } wrap.innerHTML = items.map(item => `
${escapeHtml(item.assessmentType)} - ${escapeHtml(item.title || '')}
${escapeHtml(new Intl.DateTimeFormat('id-ID', { day:'2-digit', month:'long', year:'numeric' }).format(parseLocalDate(item.date)))} • ${escapeHtml(item.subjectName || '-')}
Rata-rata Kelas : ${Number(item.average || 0).toFixed(1).replace('.', ',')} ${Number(item.average || 0) >= Number(item.kkm || 75) ? 'Baik' : 'Perlu tindak lanjut'}
Siswa Tuntas : ${item.passCount || 0}/${item.totalStudents || 0} -> ${item.totalStudents ? ((item.passCount || 0) / item.totalStudents * 100).toFixed(1).replace('.', ',') : '0'}%
Rata-rata Kelas
${Number(item.average || 0).toFixed(1).replace('.', ',')}
${Number(item.average || 0) >= Number(item.kkm || 75) ? 'Baik' : 'Perlu tindak lanjut'}
Siswa Tuntas
${item.passCount || 0}/${item.totalStudents || 0}
${item.totalStudents ? ((item.passCount || 0) / item.totalStudents * 100).toFixed(1).replace('.', ',') : '0'}%
`).join(''); wrap.querySelectorAll('[data-session-id]').forEach(node => { node.addEventListener('click', () => openHistorySession(node.dataset.sessionId)); }); } async function openHistorySession(sessionId) { try { const data = await apiGet({ action: 'assessmentSessionDetail', sessionId }); const detail = data.detail; assessmentState.currentSessionId = detail.session.id; assessmentState.meta = { classCode: detail.session.classCode, date: detail.session.date, subjectName: detail.session.subjectName, assessmentType: detail.session.assessmentType, title: detail.session.title, kkm: detail.session.kkm || 75, }; if (detail.session.classCode !== state.classCode) { state.classCode = detail.session.classCode; reportState.classCode = detail.session.classCode; localStorage.setItem('Presensi-class-code', state.classCode); localStorage.setItem('Presensi-report-class', reportState.classCode); syncClassSelectors(); await loadClassData(); } assessmentState.scores = {}; (detail.scores || []).forEach(row => { assessmentState.scores[String(row.studentId)] = { score: row.score ?? '', note: row.note || '' }; }); await loadAssessmentSubjects(); renderAssessmentSummary(); renderAssessmentStudents(); showPenilaianPage(); } catch (error) { showError(error); } } async function openAssessmentHistoryPage() { await loadAssessmentSubjects(); buildHistoryMonthOptions(); showNilaiHistoryPage(); loadAssessmentHistory().catch(showError); } async function init() { try { state.date = normalizeToSchoolDay(state.date); await loadClasses(); const savedAuthClass = getAuthClassCode(); if (savedAuthClass && isValidLoginCode(savedAuthClass)) { applyAuthClass(savedAuthClass); await loadClassData(); await loadRosterEditor(); showMenuPage(); } else { showLoginPage(); } } catch (error) { showError(error); showLoginPage(); } finally { document.body.classList.remove("preload"); document.body.classList.add("preload-ready"); } } el("#classSelect").addEventListener("change", async () => { await changeClass(el("#classSelect").value); }); el("#changePinBtn").addEventListener("click", openChangePinSheet); el("#closePinSheet").addEventListener("click", closePinSheet); el("#cancelPinBtn").addEventListener("click", closePinSheet); el("#pinOverlay").addEventListener("click", (e) => { if (e.target === el("#pinOverlay")) closePinSheet(); }); el("#savePinBtn").addEventListener("click", () => changePin().catch(showError)); el("#holidayBtn").addEventListener("click", openHolidayPage); el("#backHolidayBtn2")?.addEventListener("click", closeHolidayPage); el("#showHolidayBtn").addEventListener("click", loadHolidayList); el("#saveHolidayBtn").addEventListener("click", saveHoliday); function openAttendanceDatePicker() { const input = el("#attendanceDateInput"); if (!input) return; if (typeof input.showPicker === "function") { input.showPicker(); } else { input.click(); input.focus(); } } el("#attendanceDateDisplay")?.addEventListener("click", openAttendanceDatePicker); el("#openAttendanceDateBtn")?.addEventListener("click", openAttendanceDatePicker); el("#attendanceDateInput")?.addEventListener("change", async () => { const picked = el("#attendanceDateInput").value; if (!picked) return; state.date = picked; state.viewDate = picked; await loadClassData(); }); el("#prevDay").addEventListener("click", async () => { const d = parseLocalDate(state.date || todayIso()); d.setDate(d.getDate() - 7); state.date = todayIso(d); state.viewDate = state.date; await loadClassData(); }); el("#nextDay").addEventListener("click", async () => { const d = parseLocalDate(state.date || todayIso()); d.setDate(d.getDate() + 7); state.date = todayIso(d); state.viewDate = state.date; await loadClassData(); }); el("#q").addEventListener("input", renderList); const openSheetBtn = el("#openSheet2"); if (openSheetBtn) openSheetBtn.addEventListener("click", openSheet); const closeSheetBtn = el("#closeSheetBtn") || el("#closeSheet"); if (closeSheetBtn) closeSheetBtn.addEventListener("click", closeSheet); document.querySelectorAll(".overlay .sheet").forEach(sheet => { ["click", "mousedown", "mouseup", "touchstart", "touchmove", "touchend"].forEach(eventName => { sheet.addEventListener(eventName, event => event.stopPropagation(), { passive: true }); }); }); const overlayEl = el("#overlay"); if (overlayEl) { overlayEl.addEventListener("click", (e) => { if (e.target === overlayEl) closeSheet(); }); } el("#allH").addEventListener("click", () => bulkSet("H")); el("#clearAllAttendance").addEventListener("click", () => { if (!confirm("Hapus seluruh presensi hari ini?")) return; clearAllAttendance(); }); el("#saveBtn").addEventListener("click", saveAttendance); el("#reportMonthSelect").addEventListener("change", () => { reportState.month = Number(el("#reportMonthSelect").value); localStorage.setItem("Presensi-report-month", String(reportState.month)); }); el("#reportYearSelect").addEventListener("change", () => { reportState.year = Number(el("#reportYearSelect").value); localStorage.setItem("Presensi-report-year", String(reportState.year)); }); el("#printReportBtn").addEventListener("click", () => printMonthlyReport().catch(showError)); el("#exportExcelBtn").addEventListener("click", () => exportMonthlyReportExcel().catch(showError)); el("#manageStudentsBtn").addEventListener("click", openRoster); el("#closeRoster").addEventListener("click", closeRoster); el("#rosterOverlay").addEventListener("click", (e) => { if (e.target === el("#rosterOverlay")) closeRoster(); }); el("#showAddStudentFormBtn").addEventListener("click", showAddStudentForm); el("#cancelAddStudentBtn").addEventListener("click", hideAddStudentForm); el("#saveNewStudentBtn").addEventListener("click", () => addStudentFromForm().catch(showError)); el("#loginForm").addEventListener("submit", async (e) => { e.preventDefault(); const classCode = String(el("#loginClassSelect").value || "").trim().toUpperCase(); const pin = el("#loginPin").value.trim(); const remember = el("#rememberLogin").checked; if (!classCode) { setToast("Pilih kelas terlebih dahulu"); return; } if (!pin) { setToast("Masukkan PIN"); return; } const expectedPin = getLoginPinForClass(classCode); if (pin !== expectedPin) { setToast("PIN tidak sesuai"); return; } try { loginWithClass(classCode, remember); await loadClassData(); try { await loadRosterEditor(); } catch (rosterError) { console.warn(rosterError); } showMenuPage(); el("#loginPin").value = ""; } catch (error) { showError(error); } }); el("#clearSaved").addEventListener("click", (e) => { e.preventDefault(); clearAuth(); setToast("Hubungi Operator Sekolah!"); }); const attendanceLogoutBtn = el("#logoutBtn"); if (attendanceLogoutBtn) attendanceLogoutBtn.addEventListener("click", () => { if (state.dirtyAttendance || state.dirtyRoster) { const ok = confirm("Ada perubahan yang belum disimpan. Tetap logout?"); if (!ok) return; } logoutToLogin(); }); function clearTextSelection() { const sel = window.getSelection && window.getSelection(); if (sel) sel.removeAllRanges(); } function bindMenuCardTap(selector, action) { const card = el(selector); if (!card) return; let startX = 0; let startY = 0; let moved = false; let lastRun = 0; const runAction = () => { const now = Date.now(); if (now - lastRun < 450) return; lastRun = now; clearTextSelection(); action(); }; card.addEventListener('pointerdown', event => { if (event.pointerType === 'mouse' && event.button !== 0) return; startX = event.clientX || 0; startY = event.clientY || 0; moved = false; clearTextSelection(); event.preventDefault(); }, { passive:false }); card.addEventListener('pointermove', event => { const dx = Math.abs((event.clientX || 0) - startX); const dy = Math.abs((event.clientY || 0) - startY); if (dx > 8 || dy > 8) moved = true; }, { passive:true }); card.addEventListener('pointerup', event => { event.preventDefault(); clearTextSelection(); if (!moved) runAction(); }, { passive:false }); card.addEventListener('click', event => { event.preventDefault(); event.stopImmediatePropagation(); clearTextSelection(); if (!moved) runAction(); }, true); } bindMenuCardTap("#menuPresensiCard", showAppPage); el("#presensiBackBtn")?.addEventListener("click", backToMenu); bindMenuCardTap("#menuNilaiCard", () => openPenilaianModule().catch(showError)); el("#menuLogoutBtn")?.addEventListener("click", logoutToLogin); el("#nilaiBackBtn")?.addEventListener("click", backToMenu); el("#nilaiHistoryBtn")?.addEventListener("click", () => openAssessmentHistoryPage().catch(showError)); el("#historyBackBtn")?.addEventListener("click", () => { if (history.state?.page === "history") history.back(); else showPenilaianPage(); }); el("#historyReloadBtn")?.addEventListener("click", () => loadAssessmentHistory().catch(showError)); el("#newAssessmentBtn")?.addEventListener("click", openAssessmentSetup); el("#nilaiOptionsBtn")?.addEventListener("click", openNilaiOptions); el("#closeNilaiOptionsBtn")?.addEventListener("click", closeNilaiOptions); el("#nilaiOptionsOverlay")?.addEventListener("click", (e) => { if (e.target === el("#nilaiOptionsOverlay")) closeNilaiOptions(); }); el("#closeAssessmentSetupBtn")?.addEventListener("click", closeAssessmentSetup); el("#cancelAssessmentSetupBtn")?.addEventListener("click", closeAssessmentSetup); el("#saveAssessmentSetupBtn")?.addEventListener("click", () => saveAssessmentSetup().catch(showError)); el("#assessmentSetupOverlay")?.addEventListener("click", (e) => { if (e.target === el("#assessmentSetupOverlay")) closeAssessmentSetup(); }); el("#closeScoreDetailBtn")?.addEventListener("click", closeScoreDetail); el("#saveScoreDetailBtn")?.addEventListener("click", saveScoreDetail); el("#clearScoreDetailBtn")?.addEventListener("click", clearScoreDetail); el("#scoreDetailOverlay")?.addEventListener("click", (e) => { if (e.target === el("#scoreDetailOverlay")) closeScoreDetail(); }); el("#saveNilaiBtn")?.addEventListener("click", saveAssessmentSession); el("#historyMonthSelect")?.addEventListener("change", () => loadAssessmentHistory().catch(showError)); el("#historySubjectSelect")?.addEventListener("change", () => loadAssessmentHistory().catch(showError)); el("#historyTypeSelect")?.addEventListener("change", () => loadAssessmentHistory().catch(showError)); el("#historyClassSelect")?.addEventListener("change", () => loadAssessmentHistory().catch(showError)); function syncMobileFixedBottom() { let offset = 0; if (window.visualViewport) { offset = Math.max(0, Math.round(window.innerHeight - window.visualViewport.height - window.visualViewport.offsetTop)); } document.documentElement.style.setProperty('--fixed-bottom-offset', `${offset}px`); } window.addEventListener('resize', syncMobileFixedBottom); window.addEventListener('orientationchange', syncMobileFixedBottom); if (window.visualViewport) { window.visualViewport.addEventListener('resize', syncMobileFixedBottom); window.visualViewport.addEventListener('scroll', syncMobileFixedBottom); } document.addEventListener('pointerdown', event => { if (event.target.closest('#menuPage .module-card')) { const sel = window.getSelection && window.getSelection(); if (sel) sel.removeAllRanges(); } }, { passive:true }); syncMobileFixedBottom(); document.addEventListener('selectstart', event => { if (event.target.closest && event.target.closest('#menuPage .module-card')) { event.preventDefault(); clearTextSelection(); } }); document.addEventListener('selectionchange', () => { if (el('#menuPage')?.classList.contains('active')) clearTextSelection(); }); init();