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 = ` +
+Siswa yang dokumen wajibnya belum valid semua.
Siswa Berprestasi
+Jumlah siswa yang sudah memiliki minimal satu data prestasi.
+Pastikan master kelas dan tahun ajaran selalu siap sebelum proses promosi siswa dilakukan.
-Gunakan indikator dokumen lengkap dan tervalidasi penuh untuk memantau kualitas data siswa.
-Step paling sehat setelah ini adalah role & permission atau reporting/dashboard lanjutan.
-