From fb904f9389eef045bd6f4d57fdd11e15bf4bef6f Mon Sep 17 00:00:00 2001 From: novysan87 Date: Mon, 13 Apr 2026 06:50:37 +0000 Subject: [PATCH] Add student achievements module and improve student admin workflows --- app/Http/Controllers/DashboardController.php | 21 ++ .../PublicStudentLookupController.php | 1 + .../Controllers/SchoolClassController.php | 35 +++ .../StudentAchievementController.php | 169 +++++++++++ app/Http/Controllers/StudentController.php | 23 +- app/Models/Student.php | 5 + app/Models/StudentAchievement.php | 39 +++ ...3500_create_student_achievements_table.php | 33 +++ ...ty_photo_to_student_achievements_table.php | 22 ++ resources/js/app.js | 180 +++++++++++- resources/views/classes/index.blade.php | 127 ++++---- resources/views/dashboard.blade.php | 64 +++- resources/views/layouts/navigation.blade.php | 8 + .../views/public/student-lookup.blade.php | 277 +++++++++++------- .../student-achievements/_form.blade.php | 86 ++++++ .../student-achievements/create.blade.php | 14 + .../views/student-achievements/edit.blade.php | 15 + .../student-achievements/index.blade.php | 98 +++++++ resources/views/students/index.blade.php | 20 ++ resources/views/students/show.blade.php | 51 +++- routes/web.php | 3 + 21 files changed, 1111 insertions(+), 180 deletions(-) create mode 100644 app/Http/Controllers/StudentAchievementController.php create mode 100644 app/Models/StudentAchievement.php create mode 100644 database/migrations/2026_04_13_053500_create_student_achievements_table.php create mode 100644 database/migrations/2026_04_13_054500_add_activity_photo_to_student_achievements_table.php create mode 100644 resources/views/student-achievements/_form.blade.php create mode 100644 resources/views/student-achievements/create.blade.php create mode 100644 resources/views/student-achievements/edit.blade.php create mode 100644 resources/views/student-achievements/index.blade.php diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index d2e60f1..3943f00 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -7,6 +7,7 @@ use App\Models\Department; use App\Models\DocumentType; use App\Models\SchoolClass; use App\Models\Student; +use App\Models\StudentAchievement; use App\Models\StudentExit; use App\Models\StudentTransfer; use Illuminate\Support\Collection; @@ -24,6 +25,23 @@ class DashboardController extends Controller $completion = $this->calculateCompletion($students, $requiredDocumentTypeIds); + $studentGenderByAcademicYear = AcademicYear::query() + ->orderByDesc('name') + ->get(['id', 'name']) + ->map(function (AcademicYear $academicYear) { + $maleCount = Student::where('academic_year_id', $academicYear->id)->where('gender', 'L')->count(); + $femaleCount = Student::where('academic_year_id', $academicYear->id)->where('gender', 'P')->count(); + + return [ + 'name' => $academicYear->name, + 'male' => $maleCount, + 'female' => $femaleCount, + 'total' => $maleCount + $femaleCount, + ]; + }) + ->filter(fn (array $item) => $item['total'] > 0) + ->values(); + return view('dashboard', [ 'stats' => [ 'academicYears' => AcademicYear::count(), @@ -34,11 +52,14 @@ class DashboardController extends Controller 'studentsDropped' => StudentExit::count(), 'studentsTransferred' => StudentTransfer::count(), 'studentsGraduated' => Student::where('status', 'graduated')->count(), + 'studentAchievements' => StudentAchievement::count(), + 'studentsWithAchievements' => StudentAchievement::distinct('student_id')->count('student_id'), 'studentsCompleteDocuments' => $completion['complete'], 'studentsIncompleteDocuments' => $completion['incomplete'], 'studentsVerifiedDocuments' => $completion['verified_complete'], 'studentsUnverifiedDocuments' => $completion['verified_incomplete'], ], + 'studentGenderByAcademicYear' => $studentGenderByAcademicYear, ]); } diff --git a/app/Http/Controllers/PublicStudentLookupController.php b/app/Http/Controllers/PublicStudentLookupController.php index 3085b0a..39b88b8 100644 --- a/app/Http/Controllers/PublicStudentLookupController.php +++ b/app/Http/Controllers/PublicStudentLookupController.php @@ -25,6 +25,7 @@ class PublicStudentLookupController extends Controller 'department', 'classRoom', 'documents.documentType', + 'achievements', ]) ->where(function ($builder) use ($query) { $builder->where('student_number', 'like', "%{$query}%") diff --git a/app/Http/Controllers/SchoolClassController.php b/app/Http/Controllers/SchoolClassController.php index 8c90fb7..1b82c57 100644 --- a/app/Http/Controllers/SchoolClassController.php +++ b/app/Http/Controllers/SchoolClassController.php @@ -209,6 +209,41 @@ class SchoolClassController extends Controller return redirect()->route('classes.index')->with('success', 'Kelas berhasil dihapus.'); } + public function bulkDestroy(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'class_ids' => ['required', 'array', 'min:1'], + 'class_ids.*' => ['integer', 'exists:classes,id'], + ], [ + 'class_ids.required' => 'Pilih minimal satu kelas untuk dihapus.', + ]); + + $classes = SchoolClass::withCount(['students', 'academicHistories']) + ->whereIn('id', $validated['class_ids']) + ->get(); + + $blocked = $classes->filter(fn (SchoolClass $class) => $class->students_count > 0 || $class->academic_histories_count > 0); + + if ($blocked->isNotEmpty()) { + Alert::error('Tidak bisa dihapus', 'Sebagian kelas masih dipakai oleh siswa aktif atau histori akademik.'); + + return redirect()->route('classes.index')->withErrors([ + 'delete' => 'Beberapa kelas tidak bisa dihapus karena masih dipakai oleh data siswa atau histori akademik.', + ]); + } + + $deletedCount = 0; + + foreach ($classes as $class) { + $class->delete(); + $deletedCount++; + } + + Alert::success('Berhasil', $deletedCount . ' kelas berhasil dihapus.'); + + return redirect()->route('classes.index')->with('success', $deletedCount . ' kelas berhasil dihapus.'); + } + protected function nextLevel(?string $level): ?string { return match (strtoupper((string) $level)) { diff --git a/app/Http/Controllers/StudentAchievementController.php b/app/Http/Controllers/StudentAchievementController.php new file mode 100644 index 0000000..e93c591 --- /dev/null +++ b/app/Http/Controllers/StudentAchievementController.php @@ -0,0 +1,169 @@ +get('search')); + $academicYearId = $request->get('academic_year_id'); + $classId = $request->get('class_id'); + + $achievements = StudentAchievement::query() + ->with(['student.academicYear', 'student.department', 'student.classRoom']) + ->when($search !== '', function ($query) use ($search) { + $query->where(function ($subQuery) use ($search) { + $subQuery->where('achievement_name', 'like', "%{$search}%") + ->orWhere('category', 'like', "%{$search}%") + ->orWhere('level', 'like', "%{$search}%") + ->orWhereHas('student', function ($studentQuery) use ($search) { + $studentQuery->where('full_name', 'like', "%{$search}%") + ->orWhere('student_number', 'like', "%{$search}%") + ->orWhere('nisn', 'like', "%{$search}%"); + }); + }); + }) + ->when($academicYearId, fn ($query) => $query->whereHas('student', fn ($studentQuery) => $studentQuery->where('academic_year_id', $academicYearId))) + ->when($classId, fn ($query) => $query->whereHas('student', fn ($studentQuery) => $studentQuery->where('class_id', $classId))) + ->latest('achievement_year') + ->latest('achievement_date') + ->latest('id') + ->paginate(15) + ->withQueryString(); + + return view('student-achievements.index', [ + 'achievements' => $achievements, + 'search' => $search, + 'filters' => [ + 'academic_year_id' => $academicYearId, + 'class_id' => $classId, + ], + 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), + 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), + 'summary' => [ + 'total' => StudentAchievement::count(), + 'filtered' => $achievements->total(), + ], + ]); + } + + public function create(Request $request): View + { + $achievement = new StudentAchievement(); + + if ($request->filled('student_id')) { + $achievement->student_id = (int) $request->integer('student_id'); + } + + return view('student-achievements.create', [ + 'achievement' => $achievement, + 'students' => Student::orderBy('full_name')->get(['id', 'full_name', 'student_number', 'nisn']), + 'levelOptions' => $this->levelOptions(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $this->validateAchievement($request); + + if ($request->hasFile('certificate_file')) { + $data['certificate_file'] = $request->file('certificate_file')->store('student-achievements/certificates', 'public'); + } + + if ($request->hasFile('activity_photo')) { + $data['activity_photo'] = $request->file('activity_photo')->store('student-achievements/activity-photos', 'public'); + } + + StudentAchievement::create($data); + + return redirect()->route('student-achievements.index')->with('success', 'Data siswa berprestasi berhasil ditambahkan.'); + } + + public function edit(StudentAchievement $studentAchievement): View + { + return view('student-achievements.edit', [ + 'achievement' => $studentAchievement, + 'students' => Student::orderBy('full_name')->get(['id', 'full_name', 'student_number', 'nisn']), + 'levelOptions' => $this->levelOptions(), + ]); + } + + public function update(Request $request, StudentAchievement $studentAchievement): RedirectResponse + { + $data = $this->validateAchievement($request); + + if ($request->hasFile('certificate_file')) { + if ($studentAchievement->certificate_file && Storage::disk('public')->exists($studentAchievement->certificate_file)) { + Storage::disk('public')->delete($studentAchievement->certificate_file); + } + + $data['certificate_file'] = $request->file('certificate_file')->store('student-achievements/certificates', 'public'); + } + + if ($request->hasFile('activity_photo')) { + if ($studentAchievement->activity_photo && Storage::disk('public')->exists($studentAchievement->activity_photo)) { + Storage::disk('public')->delete($studentAchievement->activity_photo); + } + + $data['activity_photo'] = $request->file('activity_photo')->store('student-achievements/activity-photos', 'public'); + } + + $studentAchievement->update($data); + + return redirect()->route('student-achievements.index')->with('success', 'Data siswa berprestasi berhasil diperbarui.'); + } + + public function destroy(StudentAchievement $studentAchievement): RedirectResponse + { + if ($studentAchievement->certificate_file && Storage::disk('public')->exists($studentAchievement->certificate_file)) { + Storage::disk('public')->delete($studentAchievement->certificate_file); + } + + if ($studentAchievement->activity_photo && Storage::disk('public')->exists($studentAchievement->activity_photo)) { + Storage::disk('public')->delete($studentAchievement->activity_photo); + } + + $studentAchievement->delete(); + + return redirect()->route('student-achievements.index')->with('success', 'Data siswa berprestasi berhasil dihapus.'); + } + + protected function validateAchievement(Request $request): array + { + return $request->validate([ + 'student_id' => ['required', 'exists:students,id'], + 'achievement_name' => ['required', 'string', 'max:150'], + 'category' => ['nullable', 'string', 'max:100'], + 'level' => ['nullable', 'string', 'max:50'], + 'organizer' => ['nullable', 'string', 'max:150'], + 'rank' => ['nullable', 'string', 'max:100'], + 'achievement_date' => ['nullable', 'date'], + 'achievement_year' => ['nullable', 'integer', 'min:2000', 'max:' . now()->year], + 'description' => ['nullable', 'string'], + 'certificate_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png,webp', 'max:5120'], + 'activity_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + ]); + } + + protected function levelOptions(): array + { + return [ + 'Sekolah' => 'Sekolah', + 'Kecamatan' => 'Kecamatan', + 'Kabupaten/Kota' => 'Kabupaten/Kota', + 'Provinsi' => 'Provinsi', + 'Nasional' => 'Nasional', + 'Internasional' => 'Internasional', + ]; + } +} diff --git a/app/Http/Controllers/StudentController.php b/app/Http/Controllers/StudentController.php index 19cb453..1f7d9d8 100644 --- a/app/Http/Controllers/StudentController.php +++ b/app/Http/Controllers/StudentController.php @@ -65,7 +65,7 @@ class StudentController extends Controller $students = Student::query() ->select('students.*') - ->with(['academicYear', 'department', 'classRoom', 'documents.documentType']) + ->with(['academicYear', 'department', 'classRoom', 'documents.documentType', 'achievements']) ->leftJoin('academic_years', 'academic_years.id', '=', 'students.academic_year_id') ->leftJoin('departments', 'departments.id', '=', 'students.department_id') ->leftJoin('classes', 'classes.id', '=', 'students.class_id') @@ -128,6 +128,25 @@ class StudentController extends Controller : 100; $student->hover_photo_url = $photoDocument?->file_path ? asset('storage/' . $photoDocument->file_path) : null; + $achievementPriority = [ + 'Internasional' => 6, + 'Nasional' => 5, + 'Provinsi' => 4, + 'Kabupaten/Kota' => 3, + 'Kecamatan' => 2, + 'Sekolah' => 1, + ]; + + $topAchievement = $student->achievements + ->sortByDesc(fn ($achievement) => $achievementPriority[$achievement->level] ?? 0) + ->first(); + + $student->achievement_badge_level = $topAchievement?->level; + $student->achievement_badge_count = $student->achievements->count(); + $student->achievement_badge_title = $topAchievement + ? sprintf('Siswa berprestasi · %s · %s prestasi', $topAchievement->level ?: 'Prestasi', $student->achievement_badge_count) + : null; + return $student; }); @@ -177,7 +196,7 @@ class StudentController extends Controller public function show(Student $student): View { - $student->load(['academicYear', 'department', 'classRoom', 'parents', 'documents.documentType', 'academicHistories.academicYear', 'academicHistories.department', 'academicHistories.classRoom']); + $student->load(['academicYear', 'department', 'classRoom', 'parents', 'documents.documentType', 'academicHistories.academicYear', 'academicHistories.department', 'academicHistories.classRoom', 'achievements']); $requiredDocumentTypes = DocumentType::where('is_active', true) ->where('is_required', true) diff --git a/app/Models/Student.php b/app/Models/Student.php index ac9cc81..ff60b78 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -144,4 +144,9 @@ class Student extends Model { return $this->hasOne(StudentTransfer::class); } + + public function achievements(): HasMany + { + return $this->hasMany(StudentAchievement::class)->latest('achievement_year')->latest('achievement_date'); + } } diff --git a/app/Models/StudentAchievement.php b/app/Models/StudentAchievement.php new file mode 100644 index 0000000..950db6e --- /dev/null +++ b/app/Models/StudentAchievement.php @@ -0,0 +1,39 @@ + 'date', + 'achievement_year' => 'integer', + ]; + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } +} diff --git a/database/migrations/2026_04_13_053500_create_student_achievements_table.php b/database/migrations/2026_04_13_053500_create_student_achievements_table.php new file mode 100644 index 0000000..b7cff2b --- /dev/null +++ b/database/migrations/2026_04_13_053500_create_student_achievements_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete()->cascadeOnUpdate(); + $table->string('achievement_name', 150); + $table->string('category', 100)->nullable(); + $table->string('level', 50)->nullable(); + $table->string('organizer', 150)->nullable(); + $table->string('rank', 100)->nullable(); + $table->date('achievement_date')->nullable(); + $table->year('achievement_year')->nullable(); + $table->text('description')->nullable(); + $table->string('certificate_file', 255)->nullable(); + $table->timestamps(); + + $table->index(['student_id', 'achievement_year']); + }); + } + + public function down(): void + { + Schema::dropIfExists('student_achievements'); + } +}; diff --git a/database/migrations/2026_04_13_054500_add_activity_photo_to_student_achievements_table.php b/database/migrations/2026_04_13_054500_add_activity_photo_to_student_achievements_table.php new file mode 100644 index 0000000..7216944 --- /dev/null +++ b/database/migrations/2026_04_13_054500_add_activity_photo_to_student_achievements_table.php @@ -0,0 +1,22 @@ +string('activity_photo', 255)->nullable()->after('certificate_file'); + }); + } + + public function down(): void + { + Schema::table('student_achievements', function (Blueprint $table) { + $table->dropColumn('activity_photo'); + }); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 3c27784..5d5569e 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -36,6 +36,131 @@ document.addEventListener('DOMContentLoaded', () => { 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 = ` +
+
+
+
Preview Gambar
+ +
+
+ Preview +
+
+ `; + 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'; @@ -150,15 +275,20 @@ document.addEventListener('DOMContentLoaded', () => { window.addEventListener('resize', hideHoverCard); } - const bulkDeleteStudentsForm = document.querySelector('#bulk-delete-students-form'); + const setupBulkDelete = ({ formSelector, selectAllSelector, rowCheckboxSelector, buttonSelector, title, textBuilder, confirmText }) => { + const form = document.querySelector(formSelector); - if (bulkDeleteStudentsForm) { - const selectAllCheckbox = document.querySelector('#select-all-students'); - const rowCheckboxes = bulkDeleteStudentsForm.querySelectorAll('.student-row-checkbox'); - const bulkDeleteButton = document.querySelector('#bulk-delete-students-button'); + 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; @@ -167,14 +297,16 @@ document.addEventListener('DOMContentLoaded', () => { } if (selectAllCheckbox) { - selectAllCheckbox.checked = checkedCount > 0 && checkedCount === rowCheckboxes.length; - selectAllCheckbox.indeterminate = checkedCount > 0 && checkedCount < rowCheckboxes.length; + selectAllCheckbox.checked = checkedCount > 0 && enabledCheckboxes.length > 0 && checkedCount === enabledCheckboxes.length; + selectAllCheckbox.indeterminate = checkedCount > 0 && checkedCount < enabledCheckboxes.length; } }; selectAllCheckbox?.addEventListener('change', () => { rowCheckboxes.forEach((checkbox) => { - checkbox.checked = selectAllCheckbox.checked; + if (!checkbox.disabled) { + checkbox.checked = selectAllCheckbox.checked; + } }); syncBulkDeleteState(); }); @@ -183,7 +315,7 @@ document.addEventListener('DOMContentLoaded', () => { checkbox.addEventListener('change', syncBulkDeleteState); }); - bulkDeleteStudentsForm.addEventListener('submit', (event) => { + form.addEventListener('submit', (event) => { const checkedCount = Array.from(rowCheckboxes).filter((checkbox) => checkbox.checked).length; if (checkedCount === 0) { @@ -194,24 +326,44 @@ document.addEventListener('DOMContentLoaded', () => { event.preventDefault(); Swal.fire({ - title: 'Hapus banyak data siswa?', - text: `${checkedCount} data siswa yang dipilih akan dihapus. Tindakan ini tidak bisa dibatalkan.`, + title, + text: textBuilder(checkedCount), icon: 'warning', showCancelButton: true, confirmButtonColor: '#e11d48', cancelButtonColor: '#475569', - confirmButtonText: 'Ya, hapus semua', + confirmButtonText: confirmText, cancelButtonText: 'Batal', reverseButtons: true, }).then((result) => { if (result.isConfirmed) { - bulkDeleteStudentsForm.submit(); + 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 promotionForm = document.querySelector('[data-student-promotion-form]'); diff --git a/resources/views/classes/index.blade.php b/resources/views/classes/index.blade.php index c3d345a..44f680b 100644 --- a/resources/views/classes/index.blade.php +++ b/resources/views/classes/index.blade.php @@ -59,54 +59,83 @@ - - @forelse ($classes as $class) - - -
{{ $class->name }}
-
Wali Kelas: {{ $class->homeroom_teacher ?: '-' }}
- - {{ $class->level }} - {{ $class->academicYear?->name }} - {{ $class->department?->name }} - - Siswa: {{ $class->students_count }} - - - Histori: {{ $class->academic_histories_count }} - - - @if ($class->is_active) - Aktif - @else - Nonaktif - @endif - - -
- Edit - @if ($class->students_count === 0 && $class->academic_histories_count === 0) -
- @csrf - @method('DELETE') - -
- @else - - @endif -
- - - @empty - - Belum ada data kelas. - - @endforelse -
+
+ @csrf + @method('DELETE') -
- {{ $classes->links() }} -
+
+
+
Total data: {{ $classes->total() }} kelas
+
Halaman {{ $classes->currentPage() }} dari {{ $classes->lastPage() }}
+
+
+ + +
+
+ + + @forelse ($classes as $class) + + + students_count > 0 || $class->academic_histories_count > 0) ? 'disabled' : '' }}> + + +
{{ $class->name }}
+
Wali Kelas: {{ $class->homeroom_teacher ?: '-' }}
+ + {{ $class->level }} + {{ $class->academicYear?->name }} + {{ $class->department?->name }} + + Siswa: {{ $class->students_count }} + + + Histori: {{ $class->academic_histories_count }} + + + @if ($class->is_active) + Aktif + @else + Nonaktif + @endif + + +
+ Edit + @if ($class->students_count === 0 && $class->academic_histories_count === 0) + + @csrf + @method('DELETE') + + + @else + + @endif +
+ + + @empty + + Belum ada data kelas. + + @endforelse +
+ +
+
+ Menampilkan {{ $classes->firstItem() ?? 0 }} sampai {{ $classes->lastItem() ?? 0 }} dari {{ $classes->total() }} kelas. +
+
+ {{ $classes->links() }} +
+
+ diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 83e310b..b46aeb5 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -134,11 +134,15 @@ Jenis Dokumen {{ $stats['documentTypes'] }} +
+ Data Prestasi + {{ $stats['studentAchievements'] }} +
-
+
@@ -198,6 +202,21 @@

Siswa yang dokumen wajibnya belum valid semua.

+ +
+
+
+

Siswa Berprestasi

+

{{ $stats['studentsWithAchievements'] }}

+
+
+ + + +
+
+

Jumlah siswa yang sudah memiliki minimal satu data prestasi.

+
@@ -214,6 +233,9 @@ Kelola Jenis Dokumen @endif + + Kelola Siswa Berprestasi + Buka Modul Promosi Siswa @@ -246,23 +268,37 @@ Promosi Siswa Siap
+
+ Siswa Berprestasi + Siap +
- +
-
-
Prioritas data
-

Pastikan master kelas dan tahun ajaran selalu siap sebelum proses promosi siswa dilakukan.

-
-
-
Prioritas validasi
-

Gunakan indikator dokumen lengkap dan tervalidasi penuh untuk memantau kualitas data siswa.

-
-
-
Prioritas berikutnya
-

Step paling sehat setelah ini adalah role & permission atau reporting/dashboard lanjutan.

-
+ @forelse ($studentGenderByAcademicYear as $item) +
+
+
{{ $item['name'] }}
+
Total {{ $item['total'] }}
+
+
+
+
Laki-laki
+
{{ $item['male'] }}
+
+
+
Perempuan
+
{{ $item['female'] }}
+
+
+
+ @empty +
+ Belum ada data siswa yang bisa direkap per tahun angkatan. +
+ @endforelse
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index b419a24..b68e1c6 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -16,6 +16,7 @@ || request()->routeIs('student-exits.*') || request()->routeIs('student-transfers.*') || request()->routeIs('student-promotions.*') + || request()->routeIs('student-achievements.*') || request()->routeIs('student-self-service.*'); $settingsOpen = request()->routeIs('settings.*'); @@ -178,6 +179,13 @@ Data Siswa Lulus + + + + + Data Siswa Berprestasi + diff --git a/resources/views/public/student-lookup.blade.php b/resources/views/public/student-lookup.blade.php index 139f4d5..0a31f44 100644 --- a/resources/views/public/student-lookup.blade.php +++ b/resources/views/public/student-lookup.blade.php @@ -6,6 +6,11 @@ } $publicLogo = $branding['public_portal_logo'] ?: ($branding['app_logo'] ?? null); + $studentAchievements = $student?->achievements + ? $student->achievements->sortByDesc(function ($achievement) { + return sprintf('%04d-%s-%06d', $achievement->achievement_year ?? 0, optional($achievement->achievement_date)->format('Y-m-d') ?? '0000-00-00', $achievement->id); + }) + : collect(); @endphp @@ -22,7 +27,7 @@
-
+
@@ -39,53 +44,51 @@
-
-
-
-
-
Pencarian Publik
-

Cari siswa dengan cepat

-

- Hasil pencarian akan menampilkan biodata dasar, informasi akademik, dan progres kelengkapan dokumen siswa. +

@@ -94,39 +97,42 @@ @if ($student)
@if ($students->count() > 1) -
+

Ditemukan {{ $students->count() }} hasil pencarian

-

Sistem menampilkan hasil terbaik terlebih dahulu. Detail di bawah ini menggunakan hasil yang sedang dipilih.

+

Pilih siswa yang dimaksud jika ada beberapa data dengan nama atau identitas mirip.

- Multi-match nama / data mirip + Multi-match
@foreach ($students as $matchedStudent) - -
-
- @if ($matchedStudent->public_photo_url) - {{ $matchedStudent->full_name }} - @else -
- {{ strtoupper(mb_substr($matchedStudent->full_name, 0, 1)) }} -
- @endif -
-
{{ $matchedStudent->full_name }}
-
NIS: {{ $matchedStudent->student_number ?: '-' }}
-
NISN: {{ $matchedStudent->nisn ?: '-' }}
-
{{ $matchedStudent->classRoom?->name ?: '-' }} · {{ $matchedStudent->department?->name ?: '-' }}
+
+
+ @if ($matchedStudent->public_photo_url) + {{ $matchedStudent->full_name }} + @else +
+ {{ strtoupper(mb_substr($matchedStudent->full_name, 0, 1)) }} +
+ @endif + +
+
+
{{ $matchedStudent->full_name }}
+ + {{ (int) $selectedStudentId === (int) $matchedStudent->id ? 'Terpilih' : 'Pilih' }} + +
+
+
NIS: {{ $matchedStudent->student_number ?: '-' }}
+
NISN: {{ $matchedStudent->nisn ?: '-' }}
+
{{ $matchedStudent->classRoom?->name ?: '-' }} · {{ $matchedStudent->department?->name ?: '-' }}
- - {{ (int) $selectedStudentId === (int) $matchedStudent->id ? 'Terpilih' : 'Pilih' }} -
@endforeach @@ -135,33 +141,66 @@ @endif
-
-
-
+
+
+
Data ditemukan
-

{{ $student->full_name }}

-

Pencarian cocok berdasarkan {{ strtolower($matchedBy ?? 'data siswa') }} dan menampilkan ringkasan informasi siswa yang tersedia untuk publik.

+

{{ $student->full_name }}

+

Pencarian cocok berdasarkan {{ strtolower($matchedBy ?? 'data siswa') }} dan menampilkan ringkasan informasi siswa yang tersedia untuk publik.

-
-
-
{{ $requiredDocumentSummary['percentage'] }}%
-
- {{ $requiredDocumentSummary['missing'] === 0 ? 'Upload lengkap' : 'Kurang ' . $requiredDocumentSummary['missing'] . ' dokumen' }} +
+
+
{{ $student->classRoom?->name ?: '-' }}
+
Kelas saat ini
+
+
+
{{ $studentAchievements->count() }}
+
+ {{ $studentAchievements->count() > 0 ? 'Prestasi tercatat' : 'Belum ada prestasi' }}
-
-
{{ $requiredDocumentSummary['verification_percentage'] }}%
-
- {{ $requiredDocumentSummary['unverified'] === 0 && $requiredDocumentSummary['missing'] === 0 ? 'Terverifikasi penuh' : $requiredDocumentSummary['unverified'] . ' belum valid' }} -
+
+
+
+ +
+
+ @if ($student->public_photo_url) + {{ $student->full_name }} + @else +
+ {{ strtoupper(mb_substr($student->full_name, 0, 1)) }}
+ @endif +
+
{{ $student->full_name }}
+
NIS: {{ $student->student_number ?: '-' }} · NISN: {{ $student->nisn ?: '-' }}
+
+
+ +
+
+
NIS
+
{{ $student->student_number ?: '-' }}
+
+
+
NISN
+
{{ $student->nisn ?: '-' }}
+
+
+
Program
+
{{ $student->department?->name ?: '-' }}
+
+
+
Status
+
{{ ucfirst($student->status) }}
-
+

Biodata Siswa

Nama: {{ $student->full_name }}
@@ -172,7 +211,7 @@
-
+

Informasi Akademik

Tahun Angkatan: {{ $student->academicYear?->name ?: '-' }}
@@ -183,32 +222,70 @@
-
-

Status Kelengkapan

-
-
-
- Upload Dokumen Wajib - {{ $requiredDocumentSummary['uploaded'] }}/{{ $requiredDocumentSummary['total'] }} -
-
-
-
-
-
-
- Verifikasi Dokumen Wajib - {{ $requiredDocumentSummary['verified'] }}/{{ $requiredDocumentSummary['total'] }} -
-
-
-
-
+
+

Alamat Siswa

+
+
Alamat Lengkap: {{ $student->address ?: '-' }}
+
Desa/Kelurahan: {{ $student->village ?: '-' }}
+
Kecamatan: {{ $student->district ?: '-' }}
+
Kabupaten/Kota: {{ $student->city ?: '-' }}
+
Provinsi: {{ $student->province ?: '-' }}
+
Kode Pos: {{ $student->postal_code ?: '-' }}
-
+ @if ($studentAchievements->isNotEmpty()) +
+
+
+

Prestasi Siswa

+

Informasi prestasi siswa yang tercatat di sistem sekolah.

+
+
+ {{ $studentAchievements->count() }} prestasi +
+
+ +
+ @foreach ($studentAchievements as $achievement) +
+
+
+
+
{{ $achievement->achievement_name }}
+ @if ($achievement->level) + {{ $achievement->level }} + @endif + @if ($achievement->rank) + {{ $achievement->rank }} + @endif +
+
+
Kategori: {{ $achievement->category ?: '-' }}
+
Penyelenggara: {{ $achievement->organizer ?: '-' }}
+
Tahun/Tanggal: {{ $achievement->achievement_year ?: '-' }} · {{ $achievement->achievement_date?->format('d-m-Y') ?: '-' }}
+
+ @if ($achievement->description) +
{{ $achievement->description }}
+ @endif +
+
+ @if ($achievement->certificate_file) + Lihat Piagam + @endif + @if ($achievement->activity_photo) + Lihat Foto + @endif +
+
+
+ @endforeach +
+
+ @endif + +

Dokumen Kelengkapan Siswa

diff --git a/resources/views/student-achievements/_form.blade.php b/resources/views/student-achievements/_form.blade.php new file mode 100644 index 0000000..25f4ef0 --- /dev/null +++ b/resources/views/student-achievements/_form.blade.php @@ -0,0 +1,86 @@ +
+
+ +
+ + + + +
+ @error('student_id') +

{{ $message }}

+ @enderror +
+ + + + + + + + + +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ +
+ +
+

Format: PDF, JPG, JPEG, PNG, WEBP · maksimal 5 MB.

+ @if (!empty($achievement->certificate_file)) +

File tersimpan: Lihat Piagam

+ @endif + @error('certificate_file') +

{{ $message }}

+ @enderror +
+ +
+ +
+ +
+

Format: JPG, JPEG, PNG, WEBP · maksimal 5 MB.

+ @if (!empty($achievement->activity_photo)) +

File tersimpan: Lihat Foto

+ @endif + @error('activity_photo') +

{{ $message }}

+ @enderror +
+
diff --git a/resources/views/student-achievements/create.blade.php b/resources/views/student-achievements/create.blade.php new file mode 100644 index 0000000..d947e9e --- /dev/null +++ b/resources/views/student-achievements/create.blade.php @@ -0,0 +1,14 @@ + + +
+ @csrf + + @include('student-achievements._form') + +
+ Batal + +
+
+
+
diff --git a/resources/views/student-achievements/edit.blade.php b/resources/views/student-achievements/edit.blade.php new file mode 100644 index 0000000..c8fc915 --- /dev/null +++ b/resources/views/student-achievements/edit.blade.php @@ -0,0 +1,15 @@ + + +
+ @csrf + @method('PUT') + + @include('student-achievements._form') + +
+ Batal + +
+
+
+
diff --git a/resources/views/student-achievements/index.blade.php b/resources/views/student-achievements/index.blade.php new file mode 100644 index 0000000..c81bdd1 --- /dev/null +++ b/resources/views/student-achievements/index.blade.php @@ -0,0 +1,98 @@ + +
+
+
Total Prestasi
+
{{ $summary['total'] }}
+
+
+
Hasil Filter
+
{{ $summary['filtered'] }}
+
+
+ + +
+ + + + +
+ + Reset +
+ +
+ + + @forelse ($achievements as $achievement) + + +
{{ $achievement->student?->full_name ?: '-' }}
+
NIS: {{ $achievement->student?->student_number ?: '-' }} · NISN: {{ $achievement->student?->nisn ?: '-' }}
+ + +
{{ $achievement->achievement_name }}
+
+ {{ $achievement->category ?: 'Tanpa kategori' }} + @if($achievement->rank) + · {{ $achievement->rank }} + @endif +
+
Tingkat: {{ $achievement->level ?: '-' }}
+
Penyelenggara: {{ $achievement->organizer ?: 'Belum diisi' }}
+ + +
{{ $achievement->student?->academicYear?->name ?: '-' }}
+
{{ $achievement->student?->department?->name ?: '-' }}
+
{{ $achievement->student?->classRoom?->name ?: '-' }}
+ + +
+ @if($achievement->certificate_file) + @php + $certificateUrl = asset('storage/' . $achievement->certificate_file); + $certificateExtension = strtolower(pathinfo($achievement->certificate_file, PATHINFO_EXTENSION)); + $certificateIsImage = in_array($certificateExtension, ['jpg', 'jpeg', 'png', 'webp']); + @endphp + @if($certificateIsImage) + + @else + Lihat Piagam + @endif + @else + Piagam belum diunggah + @endif + + @if($achievement->activity_photo) + + @else + Foto kegiatan belum diunggah + @endif +
+ + +
Tahun: {{ $achievement->achievement_year ?: '-' }}
+
Tanggal: {{ $achievement->achievement_date?->format('d-m-Y') ?: '-' }}
+ + +
+ Edit +
+ @csrf + @method('DELETE') + +
+
+ + + @empty + + Belum ada data siswa berprestasi. + + @endforelse +
+ +
+ {{ $achievements->links() }} +
+
diff --git a/resources/views/students/index.blade.php b/resources/views/students/index.blade.php index d87284e..9c13a09 100644 --- a/resources/views/students/index.blade.php +++ b/resources/views/students/index.blade.php @@ -129,6 +129,26 @@ {{ $student->full_name }}
NIS: {{ $student->student_number ?: '-' }} · NISN: {{ $student->nisn ?: '-' }}
+ @if ($student->achievement_badge_count > 0) + @php + $achievementStars = match ($student->achievement_badge_level) { + 'Sekolah' => 1, + 'Kecamatan' => 2, + 'Kabupaten/Kota' => 3, + 'Provinsi' => 4, + 'Nasional' => 5, + 'Internasional' => 6, + default => 1, + }; + @endphp +
+ @for ($star = 0; $star < $achievementStars; $star++) + + + + @endfor +
+ @endif
{{ $student->academicYear?->name ?: '-' }}
diff --git a/resources/views/students/show.blade.php b/resources/views/students/show.blade.php index 896299c..af7dd93 100644 --- a/resources/views/students/show.blade.php +++ b/resources/views/students/show.blade.php @@ -92,7 +92,7 @@
-
+
@if ($student->academicHistories->isEmpty())
Belum ada histori akademik untuk siswa ini.
@@ -129,6 +129,55 @@
@endif + + + + + @if ($student->achievements->isEmpty()) +
Belum ada data prestasi untuk siswa ini.
+ @else +
+ @foreach ($student->achievements as $achievement) +
+
+
+
+
{{ $achievement->achievement_name }}
+ @if ($achievement->level) + {{ $achievement->level }} + @endif + @if ($achievement->rank) + {{ $achievement->rank }} + @endif +
+
Kategori: {{ $achievement->category ?: '-' }}
+
Penyelenggara: {{ $achievement->organizer ?: '-' }}
+
Tahun/Tanggal: {{ $achievement->achievement_year ?: '-' }} · {{ $achievement->achievement_date?->format('d-m-Y') ?: '-' }}
+ @if ($achievement->description) +
+ {{ $achievement->description }} +
+ @endif +
+
+ @if ($achievement->certificate_file) + Piagam + @endif + @if ($achievement->activity_photo) + + @endif + Edit +
+
+
+ @endforeach +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 7445c47..dab0930 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,7 @@ use App\Http\Controllers\StudentExitController; use App\Http\Controllers\StudentTransferController; use App\Http\Controllers\StudentPromotionController; use App\Http\Controllers\StudentSelfServiceController; +use App\Http\Controllers\StudentAchievementController; use App\Http\Controllers\UserManagementController; use Illuminate\Support\Facades\Route; @@ -42,6 +43,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::resource('academic-years', AcademicYearController::class); Route::resource('departments', DepartmentController::class); Route::post('classes/generate', [SchoolClassController::class, 'generate'])->name('classes.generate'); + Route::delete('classes/bulk-delete', [SchoolClassController::class, 'bulkDestroy'])->name('classes.bulk-destroy'); Route::resource('classes', SchoolClassController::class); Route::resource('document-types', DocumentTypeController::class); Route::prefix('settings')->name('settings.')->group(function () { @@ -70,6 +72,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::resource('students', StudentController::class); Route::get('student-promotions', [StudentPromotionController::class, 'index'])->name('student-promotions.index'); Route::post('student-promotions', [StudentPromotionController::class, 'store'])->name('student-promotions.store'); + Route::resource('student-achievements', StudentAchievementController::class)->except(['show']); Route::get('student-self-service', [StudentSelfServiceController::class, 'index'])->name('student-self-service.index'); Route::resource('students.parents', StudentParentController::class)->except(['index', 'show']); Route::resource('students.documents', StudentDocumentController::class)->except(['index', 'show']);