diff --git a/app/Http/Controllers/Api/Integration/StudentSyncController.php b/app/Http/Controllers/Api/Integration/StudentSyncController.php index b370867..798a9a6 100644 --- a/app/Http/Controllers/Api/Integration/StudentSyncController.php +++ b/app/Http/Controllers/Api/Integration/StudentSyncController.php @@ -16,7 +16,17 @@ class StudentSyncController extends Controller abort_unless(collect($token->abilities ?? [])->contains('students'), 403, 'Token tidak memiliki akses ke data siswa.'); + $classLevel = strtoupper(trim((string) $request->query('class_level', ''))); + $items = Student::query() + ->when(in_array($classLevel, ['X', 'XI', 'XII'], true), function ($query) use ($classLevel) { + $query->whereHas('classRoom', fn ($classQuery) => $classQuery->where('level', $classLevel)); + }) + ->with([ + 'academicYear:id,name,start_year,end_year,is_active', + 'department:id,code,name,description,is_active', + 'classRoom:id,academic_year_id,department_id,name,level,is_active', + ]) ->orderBy('full_name') ->get([ 'id', @@ -24,16 +34,74 @@ class StudentSyncController extends Controller 'nisn', 'full_name', 'gender', + 'place_of_birth', + 'date_of_birth', + 'nationality', + 'address', + 'postal_code', 'academic_year_id', 'department_id', 'class_id', + 'entry_date', 'status', 'phone_number', 'email', 'photo_path', 'notes', 'updated_at', - ]); + ]) + ->map(function (Student $student) { + return [ + 'id' => $student->id, + 'student_number' => $student->student_number, + 'nisn' => $student->nisn, + 'full_name' => $student->full_name, + 'gender' => $student->gender, + 'place_of_birth' => $student->place_of_birth, + 'date_of_birth' => $student->date_of_birth?->toDateString(), + 'nationality' => $student->nationality, + 'address' => $student->address, + 'postal_code' => $student->postal_code, + 'academic_year_id' => $student->academic_year_id, + 'academic_year_name' => $student->academicYear?->name, + 'academic_year' => $student->academicYear ? [ + 'id' => $student->academicYear->id, + 'name' => $student->academicYear->name, + 'start_year' => $student->academicYear->start_year, + 'end_year' => $student->academicYear->end_year, + 'is_active' => (bool) $student->academicYear->is_active, + ] : null, + 'department_id' => $student->department_id, + 'department_code' => $student->department?->code, + 'department_name' => $student->department?->name, + 'department' => $student->department ? [ + 'id' => $student->department->id, + 'code' => $student->department->code, + 'name' => $student->department->name, + 'description' => $student->department->description, + 'is_active' => (bool) $student->department->is_active, + ] : null, + 'class_id' => $student->class_id, + 'class_name' => $student->classRoom?->name, + 'class_level' => $student->classRoom?->level, + 'classroom' => $student->classRoom ? [ + 'id' => $student->classRoom->id, + 'name' => $student->classRoom->name, + 'level' => $student->classRoom->level, + 'academic_year_id' => $student->classRoom->academic_year_id, + 'department_id' => $student->classRoom->department_id, + 'is_active' => (bool) $student->classRoom->is_active, + ] : null, + 'entry_date' => $student->entry_date?->toDateString(), + 'status' => $student->status, + 'phone_number' => $student->phone_number, + 'email' => $student->email, + 'photo_path' => $student->photo_path, + 'notes' => $student->notes, + 'updated_at' => $student->updated_at?->toISOString(), + ]; + }) + ->values(); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/GraduatedStudentController.php b/app/Http/Controllers/GraduatedStudentController.php index c2eb1c4..316f708 100644 --- a/app/Http/Controllers/GraduatedStudentController.php +++ b/app/Http/Controllers/GraduatedStudentController.php @@ -21,6 +21,7 @@ class GraduatedStudentController extends Controller $academicYearId = $request->get('academic_year_id'); $departmentId = $request->get('department_id'); $classId = $request->get('class_id'); + $documentStatus = (string) $request->get('document_status', 'all'); $sort = (string) $request->get('sort', 'full_name'); $direction = strtolower((string) $request->get('direction', 'asc')) === 'desc' ? 'desc' : 'asc'; @@ -38,6 +39,14 @@ class GraduatedStudentController extends Controller $sort = 'full_name'; } + $documentFields = [ + 'skl_file_path', + 'ijazah_file_path', + 'transcript_file_path', + 'ukk_certificate_file_path', + 'lsp_certificate_file_path', + ]; + $graduations = StudentGraduation::query() ->with(['academicYear', 'department', 'classRoom']) ->when($search !== '', function ($query) use ($search) { @@ -50,17 +59,46 @@ class GraduatedStudentController extends Controller ->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId)) ->when($departmentId, fn ($query) => $query->where('department_id', $departmentId)) ->when($classId, fn ($query) => $query->where('class_id', $classId)) + ->when($documentStatus === 'incomplete', function ($query) use ($documentFields) { + foreach ($documentFields as $field) { + $query->orWhereNull($field); + } + }) + ->when($documentStatus === 'complete', function ($query) use ($documentFields) { + foreach ($documentFields as $field) { + $query->whereNotNull($field); + } + }) ->orderBy($allowedSorts[$sort], $direction) ->orderBy('id', $direction) ->paginate(10) ->withQueryString(); + $totalGraduations = StudentGraduation::count(); $summary = [ - 'total' => $graduations->total(), + 'total' => $totalGraduations, + 'filtered' => $graduations->total(), 'with_skl' => StudentGraduation::query()->whereNotNull('skl_file_path')->count(), 'with_ijazah' => StudentGraduation::query()->whereNotNull('ijazah_file_path')->count(), + 'with_transcript' => StudentGraduation::query()->whereNotNull('transcript_file_path')->count(), + 'with_ukk_certificate' => StudentGraduation::query()->whereNotNull('ukk_certificate_file_path')->count(), + 'with_lsp_certificate' => StudentGraduation::query()->whereNotNull('lsp_certificate_file_path')->count(), + 'this_year' => StudentGraduation::query()->whereYear('graduation_date', now()->year)->count(), + 'latest_graduation_date' => StudentGraduation::query()->max('graduation_date'), ]; + $graduations->getCollection()->transform(function (StudentGraduation $graduation) use ($documentFields) { + $availableDocuments = collect($documentFields)->filter(fn ($field) => filled($graduation->{$field}))->count(); + $graduation->documents_total = count($documentFields); + $graduation->documents_available = $availableDocuments; + $graduation->documents_missing = max($graduation->documents_total - $availableDocuments, 0); + $graduation->documents_progress_percentage = $graduation->documents_total > 0 + ? (int) round(($availableDocuments / $graduation->documents_total) * 100) + : 100; + + return $graduation; + }); + return view('graduated-students.index', [ 'graduations' => $graduations, 'search' => $search, @@ -68,10 +106,16 @@ class GraduatedStudentController extends Controller 'academic_year_id' => $academicYearId, 'department_id' => $departmentId, 'class_id' => $classId, + 'document_status' => $documentStatus, ], 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), 'departments' => Department::orderBy('name')->pluck('name', 'id'), 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), + 'documentStatusOptions' => [ + 'all' => 'Semua Dokumen', + 'complete' => 'Dokumen Lengkap', + 'incomplete' => 'Dokumen Belum Lengkap', + ], 'sort' => $sort, 'direction' => $direction, 'summary' => $summary, @@ -94,11 +138,20 @@ class GraduatedStudentController extends Controller 'graduation_note' => ['nullable', 'string'], 'skl_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], 'ijazah_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], + 'transcript_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], + 'ukk_certificate_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], + 'lsp_certificate_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], ], [ 'skl_file.mimes' => 'File SKL harus berupa PDF, JPG, JPEG, atau PNG.', 'skl_file.max' => 'Ukuran file SKL maksimal 4 MB.', 'ijazah_file.mimes' => 'File ijazah harus berupa PDF, JPG, JPEG, atau PNG.', 'ijazah_file.max' => 'Ukuran file ijazah maksimal 4 MB.', + 'transcript_file.mimes' => 'File transkrip nilai harus berupa PDF, JPG, JPEG, atau PNG.', + 'transcript_file.max' => 'Ukuran file transkrip nilai maksimal 4 MB.', + 'ukk_certificate_file.mimes' => 'File sertifikat UKK harus berupa PDF, JPG, JPEG, atau PNG.', + 'ukk_certificate_file.max' => 'Ukuran file sertifikat UKK maksimal 4 MB.', + 'lsp_certificate_file.mimes' => 'File sertifikat LSP harus berupa PDF, JPG, JPEG, atau PNG.', + 'lsp_certificate_file.max' => 'Ukuran file sertifikat LSP maksimal 4 MB.', ]); $graduatedStudent->graduation_date = $validated['graduation_date'] ?? $graduatedStudent->graduation_date; @@ -132,6 +185,48 @@ class GraduatedStudentController extends Controller $graduatedStudent->ijazah_file_size = $file->getSize(); } + if ($request->hasFile('transcript_file')) { + if ($graduatedStudent->transcript_file_path && Storage::disk('public')->exists($graduatedStudent->transcript_file_path)) { + Storage::disk('public')->delete($graduatedStudent->transcript_file_path); + } + + $file = $request->file('transcript_file'); + $path = $file->store("student-graduation-documents/{$graduatedStudent->student_id}/transcript", 'public'); + + $graduatedStudent->transcript_file_name = $file->getClientOriginalName(); + $graduatedStudent->transcript_file_path = $path; + $graduatedStudent->transcript_mime_type = $file->getMimeType(); + $graduatedStudent->transcript_file_size = $file->getSize(); + } + + if ($request->hasFile('ukk_certificate_file')) { + if ($graduatedStudent->ukk_certificate_file_path && Storage::disk('public')->exists($graduatedStudent->ukk_certificate_file_path)) { + Storage::disk('public')->delete($graduatedStudent->ukk_certificate_file_path); + } + + $file = $request->file('ukk_certificate_file'); + $path = $file->store("student-graduation-documents/{$graduatedStudent->student_id}/ukk-certificate", 'public'); + + $graduatedStudent->ukk_certificate_file_name = $file->getClientOriginalName(); + $graduatedStudent->ukk_certificate_file_path = $path; + $graduatedStudent->ukk_certificate_mime_type = $file->getMimeType(); + $graduatedStudent->ukk_certificate_file_size = $file->getSize(); + } + + if ($request->hasFile('lsp_certificate_file')) { + if ($graduatedStudent->lsp_certificate_file_path && Storage::disk('public')->exists($graduatedStudent->lsp_certificate_file_path)) { + Storage::disk('public')->delete($graduatedStudent->lsp_certificate_file_path); + } + + $file = $request->file('lsp_certificate_file'); + $path = $file->store("student-graduation-documents/{$graduatedStudent->student_id}/lsp-certificate", 'public'); + + $graduatedStudent->lsp_certificate_file_name = $file->getClientOriginalName(); + $graduatedStudent->lsp_certificate_file_path = $path; + $graduatedStudent->lsp_certificate_mime_type = $file->getMimeType(); + $graduatedStudent->lsp_certificate_file_size = $file->getSize(); + } + $graduatedStudent->save(); Alert::success('Berhasil', 'Arsip siswa lulus berhasil diperbarui.'); @@ -141,11 +236,19 @@ class GraduatedStudentController extends Controller public function previewDocument(StudentGraduation $graduatedStudent, string $document): View { - abort_unless(in_array($document, ['skl', 'ijazah'], true), 404); + abort_unless(in_array($document, ['skl', 'ijazah', 'transcript', 'ukk-certificate', 'lsp-certificate'], true), 404); - $filePathField = $document === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; - $fileNameField = $document === 'skl' ? 'skl_file_name' : 'ijazah_file_name'; - $mimeTypeField = $document === 'skl' ? 'skl_mime_type' : 'ijazah_mime_type'; + $documentConfig = [ + 'skl' => ['path' => 'skl_file_path', 'name' => 'skl_file_name', 'mime' => 'skl_mime_type', 'label' => 'SKL'], + 'ijazah' => ['path' => 'ijazah_file_path', 'name' => 'ijazah_file_name', 'mime' => 'ijazah_mime_type', 'label' => 'Ijazah'], + 'transcript' => ['path' => 'transcript_file_path', 'name' => 'transcript_file_name', 'mime' => 'transcript_mime_type', 'label' => 'Transkrip Nilai'], + 'ukk-certificate' => ['path' => 'ukk_certificate_file_path', 'name' => 'ukk_certificate_file_name', 'mime' => 'ukk_certificate_mime_type', 'label' => 'Sertifikat UKK'], + 'lsp-certificate' => ['path' => 'lsp_certificate_file_path', 'name' => 'lsp_certificate_file_name', 'mime' => 'lsp_certificate_mime_type', 'label' => 'Sertifikat LSP'], + ][$document]; + + $filePathField = $documentConfig['path']; + $fileNameField = $documentConfig['name']; + $mimeTypeField = $documentConfig['mime']; abort_unless($graduatedStudent->{$filePathField} && Storage::disk('public')->exists($graduatedStudent->{$filePathField}), 404); @@ -157,7 +260,7 @@ class GraduatedStudentController extends Controller return view('graduated-students.preview-document', [ 'graduation' => $graduatedStudent, 'documentType' => $document, - 'documentLabel' => $document === 'skl' ? 'SKL' : 'Ijazah', + 'documentLabel' => $documentConfig['label'], 'fileName' => $graduatedStudent->{$fileNameField}, 'fileUrl' => $fileUrl, 'mimeType' => $mimeType, diff --git a/app/Http/Controllers/GraduationBulkUploadController.php b/app/Http/Controllers/GraduationBulkUploadController.php index 99bf578..6eeff86 100644 --- a/app/Http/Controllers/GraduationBulkUploadController.php +++ b/app/Http/Controllers/GraduationBulkUploadController.php @@ -20,6 +20,9 @@ class GraduationBulkUploadController extends Controller 'documentTypes' => [ 'skl' => 'SKL', 'ijazah' => 'Ijazah', + 'transcript' => 'Transkrip Nilai', + 'ukk-certificate' => 'Sertifikat UKK', + 'lsp-certificate' => 'Sertifikat LSP', ], 'matchByOptions' => [ 'student_number' => 'NIS', @@ -31,15 +34,15 @@ class GraduationBulkUploadController extends Controller public function preview(Request $request): RedirectResponse { $validated = $request->validate([ - 'document_type' => ['required', 'in:skl,ijazah'], + 'document_type' => ['required', 'in:skl,ijazah,transcript,ukk-certificate,lsp-certificate'], 'match_by' => ['required', 'in:student_number,nisn'], 'documents' => ['required', 'array', 'min:1', 'max:10'], - 'documents.*' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:2048'], + 'documents.*' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], ], [ 'documents.required' => 'Pilih minimal satu file dokumen untuk dipreview.', 'documents.max' => 'Maksimal 10 file per batch upload.', 'documents.*.mimes' => 'Dokumen hanya boleh berupa PDF, JPG, JPEG, atau PNG.', - 'documents.*.max' => 'Ukuran tiap dokumen maksimal 2 MB.', + 'documents.*.max' => 'Ukuran tiap dokumen maksimal 4 MB.', ]); $graduations = StudentGraduation::query()->get()->keyBy($validated['match_by']); @@ -59,7 +62,13 @@ class GraduationBulkUploadController extends Controller $baseName = pathinfo($originalName, PATHINFO_FILENAME); $normalizedKey = trim($baseName); $graduation = $graduations->get($normalizedKey); - $existingPathField = $validated['document_type'] === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; + $existingPathField = [ + 'skl' => 'skl_file_path', + 'ijazah' => 'ijazah_file_path', + 'transcript' => 'transcript_file_path', + 'ukk-certificate' => 'ukk_certificate_file_path', + 'lsp-certificate' => 'lsp_certificate_file_path', + ][$validated['document_type']]; $storedTempPath = $file->store('temp/graduation-bulk-upload', 'public'); @@ -93,7 +102,13 @@ class GraduationBulkUploadController extends Controller $preview = [ 'token' => (string) Str::uuid(), 'document_type' => $validated['document_type'], - 'document_label' => $validated['document_type'] === 'skl' ? 'SKL' : 'Ijazah', + 'document_label' => [ + 'skl' => 'SKL', + 'ijazah' => 'Ijazah', + 'transcript' => 'Transkrip Nilai', + 'ukk-certificate' => 'Sertifikat UKK', + 'lsp-certificate' => 'Sertifikat LSP', + ][$validated['document_type']], 'match_by' => $validated['match_by'], 'match_by_label' => $validated['match_by'] === 'student_number' ? 'NIS' : 'NISN', 'rows' => $rows, @@ -120,10 +135,18 @@ class GraduationBulkUploadController extends Controller } $documentType = $preview['document_type']; - $fileNameField = $documentType === 'skl' ? 'skl_file_name' : 'ijazah_file_name'; - $filePathField = $documentType === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; - $mimeTypeField = $documentType === 'skl' ? 'skl_mime_type' : 'ijazah_mime_type'; - $fileSizeField = $documentType === 'skl' ? 'skl_file_size' : 'ijazah_file_size'; + $documentConfig = [ + 'skl' => ['name' => 'skl_file_name', 'path' => 'skl_file_path', 'mime' => 'skl_mime_type', 'size' => 'skl_file_size'], + 'ijazah' => ['name' => 'ijazah_file_name', 'path' => 'ijazah_file_path', 'mime' => 'ijazah_mime_type', 'size' => 'ijazah_file_size'], + 'transcript' => ['name' => 'transcript_file_name', 'path' => 'transcript_file_path', 'mime' => 'transcript_mime_type', 'size' => 'transcript_file_size'], + 'ukk-certificate' => ['name' => 'ukk_certificate_file_name', 'path' => 'ukk_certificate_file_path', 'mime' => 'ukk_certificate_mime_type', 'size' => 'ukk_certificate_file_size'], + 'lsp-certificate' => ['name' => 'lsp_certificate_file_name', 'path' => 'lsp_certificate_file_path', 'mime' => 'lsp_certificate_mime_type', 'size' => 'lsp_certificate_file_size'], + ][$documentType]; + + $fileNameField = $documentConfig['name']; + $filePathField = $documentConfig['path']; + $mimeTypeField = $documentConfig['mime']; + $fileSizeField = $documentConfig['size']; $processed = 0; $skipped = 0; diff --git a/app/Http/Controllers/PublicStudentLookupController.php b/app/Http/Controllers/PublicStudentLookupController.php index 9aa1222..ae30a46 100644 --- a/app/Http/Controllers/PublicStudentLookupController.php +++ b/app/Http/Controllers/PublicStudentLookupController.php @@ -144,12 +144,20 @@ class PublicStudentLookupController extends Controller public function downloadGraduationDocument(Student $student, string $document): StreamedResponse { abort_unless($student->status === 'graduated', 404); - abort_unless(in_array($document, ['skl', 'ijazah'], true), 404); + abort_unless(in_array($document, ['skl', 'ijazah', 'transcript', 'ukk-certificate', 'lsp-certificate'], true), 404); $graduation = StudentGraduation::query()->where('student_id', $student->id)->firstOrFail(); - $filePathField = $document === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; - $fileNameField = $document === 'skl' ? 'skl_file_name' : 'ijazah_file_name'; + $documentConfig = [ + 'skl' => ['path' => 'skl_file_path', 'name' => 'skl_file_name'], + 'ijazah' => ['path' => 'ijazah_file_path', 'name' => 'ijazah_file_name'], + 'transcript' => ['path' => 'transcript_file_path', 'name' => 'transcript_file_name'], + 'ukk-certificate' => ['path' => 'ukk_certificate_file_path', 'name' => 'ukk_certificate_file_name'], + 'lsp-certificate' => ['path' => 'lsp_certificate_file_path', 'name' => 'lsp_certificate_file_name'], + ][$document]; + + $filePathField = $documentConfig['path']; + $fileNameField = $documentConfig['name']; abort_unless($graduation->{$filePathField} && Storage::disk('public')->exists($graduation->{$filePathField}), 404); diff --git a/app/Http/Controllers/PublicStudentSelfServiceController.php b/app/Http/Controllers/PublicStudentSelfServiceController.php index 5dbe1c7..902502b 100644 --- a/app/Http/Controllers/PublicStudentSelfServiceController.php +++ b/app/Http/Controllers/PublicStudentSelfServiceController.php @@ -125,6 +125,7 @@ class PublicStudentSelfServiceController extends Controller 'date_of_birth' => ['nullable', 'date'], 'religion' => ['nullable', 'string', 'max:50'], 'nationality' => ['nullable', 'string', 'max:50'], + 'nik' => ['nullable', 'string', 'max:30', Rule::unique('students', 'nik')->ignore($student->id)], 'family_card_number' => ['nullable', 'string', 'max:30'], 'child_order' => ['nullable', 'integer', 'min:1', 'max:50'], 'number_of_siblings' => ['nullable', 'integer', 'min:0', 'max:50'], @@ -171,6 +172,7 @@ class PublicStudentSelfServiceController extends Controller 'date_of_birth' => $data['date_of_birth'] ?? null, 'religion' => $data['religion'] ?? null, 'nationality' => $data['nationality'] ?? 'Indonesia', + 'nik' => $data['nik'] ?? null, 'family_card_number' => $data['family_card_number'] ?? null, 'child_order' => $data['child_order'] ?? null, 'number_of_siblings' => $data['number_of_siblings'] ?? null, diff --git a/app/Http/Controllers/StudentAchievementController.php b/app/Http/Controllers/StudentAchievementController.php index e93c591..8b152b2 100644 --- a/app/Http/Controllers/StudentAchievementController.php +++ b/app/Http/Controllers/StudentAchievementController.php @@ -18,8 +18,10 @@ class StudentAchievementController extends Controller $search = trim((string) $request->get('search')); $academicYearId = $request->get('academic_year_id'); $classId = $request->get('class_id'); + $level = trim((string) $request->get('level')); + $category = trim((string) $request->get('category')); - $achievements = StudentAchievement::query() + $achievementsQuery = StudentAchievement::query() ->with(['student.academicYear', 'student.department', 'student.classRoom']) ->when($search !== '', function ($query) use ($search) { $query->where(function ($subQuery) use ($search) { @@ -35,25 +37,67 @@ class StudentAchievementController extends Controller }) ->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))) + ->when($level !== '', fn ($query) => $query->where('level', $level)) + ->when($category !== '', fn ($query) => $query->where('category', $category)); + + $achievements = (clone $achievementsQuery) ->latest('achievement_year') ->latest('achievement_date') ->latest('id') ->paginate(15) ->withQueryString(); + $levelCounts = StudentAchievement::query() + ->selectRaw('COALESCE(level, ?) as level_label, COUNT(*) as total', ['Tanpa tingkat']) + ->groupBy('level') + ->orderByDesc('total') + ->pluck('total', 'level_label'); + + $withCertificateCount = StudentAchievement::query()->whereNotNull('certificate_file')->count(); + $withPhotoCount = StudentAchievement::query()->whereNotNull('activity_photo')->count(); + $thisYearCount = StudentAchievement::query()->where('achievement_year', now()->year)->count(); + + $recentAchievements = StudentAchievement::query() + ->with(['student:id,full_name']) + ->latest('achievement_date') + ->latest('achievement_year') + ->latest('id') + ->limit(3) + ->get(); + + $categoryOptions = StudentAchievement::query() + ->whereNotNull('category') + ->where('category', '!=', '') + ->distinct() + ->orderBy('category') + ->pluck('category', 'category'); + return view('student-achievements.index', [ 'achievements' => $achievements, 'search' => $search, 'filters' => [ 'academic_year_id' => $academicYearId, 'class_id' => $classId, + 'level' => $level, + 'category' => $category, ], 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), + 'categoryOptions' => $categoryOptions, + 'levelOptions' => $this->levelOptions(), 'summary' => [ 'total' => StudentAchievement::count(), 'filtered' => $achievements->total(), + 'students' => StudentAchievement::distinct('student_id')->count('student_id'), + 'latest_year' => StudentAchievement::max('achievement_year'), + 'top_level' => $levelCounts->keys()->first(), + 'top_level_total' => $levelCounts->first(), + 'with_certificate' => $withCertificateCount, + 'without_certificate' => max(StudentAchievement::count() - $withCertificateCount, 0), + 'with_photo' => $withPhotoCount, + 'this_year' => $thisYearCount, ], + 'recentAchievements' => $recentAchievements, ]); } diff --git a/app/Http/Controllers/StudentController.php b/app/Http/Controllers/StudentController.php index 64cf6f0..b4a04d9 100644 --- a/app/Http/Controllers/StudentController.php +++ b/app/Http/Controllers/StudentController.php @@ -75,6 +75,7 @@ class StudentController extends Controller $subQuery->where('students.full_name', 'like', "%{$search}%") ->orWhere('students.student_number', 'like', "%{$search}%") ->orWhere('students.nisn', 'like', "%{$search}%") + ->orWhere('students.nik', 'like', "%{$search}%") ->orWhere('students.phone_number', 'like', "%{$search}%"); }); }) @@ -161,6 +162,7 @@ class StudentController extends Controller $subQuery->where('students.full_name', 'like', "%{$search}%") ->orWhere('students.student_number', 'like', "%{$search}%") ->orWhere('students.nisn', 'like', "%{$search}%") + ->orWhere('students.nik', 'like', "%{$search}%") ->orWhere('students.phone_number', 'like', "%{$search}%"); }); }) @@ -276,9 +278,14 @@ class StudentController extends Controller public function edit(Student $student): View { - return view('students.edit', array_merge($this->formData(), [ - 'student' => $student, - ])); + $student->loadMissing('classRoom'); + + return view('students.edit', array_merge( + $this->formData($student), + [ + 'student' => $student, + ] + )); } public function update(Request $request, Student $student): RedirectResponse @@ -395,14 +402,34 @@ class StudentController extends Controller $payload = Arr::only($row['normalized'], [ 'student_number', 'nisn', + 'nik', 'full_name', 'gender', + 'place_of_birth', + 'date_of_birth', + 'religion', + 'nationality', + 'family_card_number', + 'child_order', + 'number_of_siblings', + 'phone_number', + 'email', + 'address', + 'province_id', + 'regency_id', + 'district_id', + 'village_id', + 'province', + 'city', + 'district', + 'village', + 'postal_code', 'academic_year_id', 'department_id', 'class_id', 'entry_date', 'status', - 'phone_number', + 'notes', ]); $lookup = null; @@ -422,12 +449,14 @@ class StudentController extends Controller $lookup->update($payload); $this->syncAcademicHistory($lookup, $payload); + $this->syncImportedParents($lookup, $row['normalized']['parents'] ?? []); $updated++; continue; } $student = Student::create($payload); $this->syncAcademicHistory($student, $payload); + $this->syncImportedParents($student, $row['normalized']['parents'] ?? []); $inserted++; } }); @@ -441,7 +470,7 @@ class StudentController extends Controller ->with('success', "Import data siswa selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui."); } - public function downloadImportTemplate(): Response + public function downloadImportTemplate() { $this->ensureAdmin(); @@ -452,32 +481,112 @@ class StudentController extends Controller $headers = [ 'NIS', 'NISN', + 'NIK', 'Nama Lengkap', 'Jenis Kelamin (L/P)', + 'Tempat Lahir', + 'Tanggal Lahir (YYYY-MM-DD)', + 'Agama', + 'Kewarganegaraan', + 'No. Kartu Keluarga', + 'Anak Ke', + 'Jumlah Saudara', + 'Nomor HP', + 'Email', + 'Alamat', + 'Provinsi', + 'Kabupaten/Kota', + 'Kecamatan', + 'Kelurahan/Desa', + 'Kode Pos', 'Tahun Ajaran', 'Program Keahlian', 'Kelas', 'Tanggal Masuk (YYYY-MM-DD)', 'Status', - 'Nomor HP', + 'Catatan', + 'Nama Ayah', + 'NIK Ayah', + 'Tahun Lahir Ayah', + 'Pendidikan Ayah', + 'Pekerjaan Ayah', + 'No. HP Ayah', + 'Alamat Ayah', + 'Status Hidup Ayah', + 'Nama Ibu', + 'NIK Ibu', + 'Tahun Lahir Ibu', + 'Pendidikan Ibu', + 'Pekerjaan Ibu', + 'No. HP Ibu', + 'Alamat Ibu', + 'Status Hidup Ibu', + 'Nama Wali', + 'NIK Wali', + 'Tahun Lahir Wali', + 'Pendidikan Wali', + 'Pekerjaan Wali', + 'No. HP Wali', + 'Alamat Wali', + 'Status Hidup Wali', ]; $sheet->fromArray($headers, null, 'A1'); $sheet->fromArray([ '2026-001', '9988776655', + '3512345678901234', 'Contoh Nama Siswa', 'L', + 'Malang', + '2010-01-15', + 'Islam', + 'Indonesia', + '3512345678901235', + '1', + '2', + '081234567890', + 'contoh@siswa.local', + 'Jl. Contoh No. 1', + 'Jawa Timur', + 'Kota Malang', + 'Klojen', + 'Kauman', + '65119', AcademicYear::orderBy('name')->value('name') ?? '2026/2027', Department::orderBy('name')->value('name') ?? 'DKV', SchoolClass::orderBy('name')->value('name') ?? 'X DKV 1', '2026-07-15', 'active', - '081234567890', + 'Catatan opsional', + 'Budi Santoso', + '3512345678901111', + '1980', + 'SMA', + 'Wiraswasta', + '081200000001', + 'Jl. Ayah No. 1', + 'Ya', + 'Siti Aminah', + '3512345678902222', + '1982', + 'SMA', + 'Ibu Rumah Tangga', + '081200000002', + 'Jl. Ibu No. 1', + 'Ya', + 'Ahmad Wali', + '3512345678903333', + '1975', + 'S1', + 'Pegawai Swasta', + '081200000003', + 'Jl. Wali No. 1', + 'Ya', ], null, 'A2'); - foreach (range('A', 'J') as $column) { - $sheet->getColumnDimension($column)->setAutoSize(true); + for ($columnIndex = 1; $columnIndex <= count($headers); $columnIndex++) { + $sheet->getColumnDimensionByColumn($columnIndex)->setAutoSize(true); } $referenceSheet = $spreadsheet->createSheet(); @@ -502,14 +611,54 @@ class StudentController extends Controller ['Kolom', 'Keterangan'], ['NIS', 'Boleh kosong, tapi idealnya diisi untuk identitas lokal sekolah.'], ['NISN', 'Boleh kosong jika belum ada.'], + ['NIK', 'Opsional, tetapi jika diisi harus unik.'], ['Nama Lengkap', 'Wajib diisi.'], ['Jenis Kelamin (L/P)', 'Isi L atau P.'], + ['Tempat Lahir', 'Opsional.'], + ['Tanggal Lahir (YYYY-MM-DD)', 'Format contoh: 2010-01-15.'], + ['Agama', 'Opsional, contoh: Islam, Kristen, Katolik, Hindu, Buddha, Konghucu.'], + ['Kewarganegaraan', 'Opsional, default aman: Indonesia.'], + ['No. Kartu Keluarga', 'Opsional.'], + ['Anak Ke', 'Opsional, angka bulat minimal 1.'], + ['Jumlah Saudara', 'Opsional, angka bulat minimal 0.'], + ['Nomor HP', 'Opsional.'], + ['Email', 'Opsional, harus format email valid bila diisi.'], + ['Alamat', 'Opsional.'], + ['Provinsi', 'Opsional, isi nama persis seperti data master wilayah.'], + ['Kabupaten/Kota', 'Opsional, isi nama persis seperti data master wilayah.'], + ['Kecamatan', 'Opsional, isi nama persis seperti data master wilayah.'], + ['Kelurahan/Desa', 'Opsional, isi nama persis seperti data master wilayah.'], + ['Kode Pos', 'Opsional.'], ['Tahun Ajaran', 'Isi dengan nama tahun ajaran persis seperti sheet Referensi.'], ['Program Keahlian', 'Isi dengan nama jurusan persis seperti sheet Referensi.'], ['Kelas', 'Isi dengan nama kelas persis seperti sheet Referensi.'], ['Tanggal Masuk (YYYY-MM-DD)', 'Format contoh: 2026-07-15.'], ['Status', 'Gunakan: active, inactive, transferred, graduated, dropped.'], - ['Nomor HP', 'Opsional.'], + ['Catatan', 'Opsional.'], + ['Nama Ayah', 'Opsional.'], + ['NIK Ayah', 'Opsional.'], + ['Tahun Lahir Ayah', 'Opsional, angka 4 digit.'], + ['Pendidikan Ayah', 'Opsional.'], + ['Pekerjaan Ayah', 'Opsional.'], + ['No. HP Ayah', 'Opsional.'], + ['Alamat Ayah', 'Opsional.'], + ['Status Hidup Ayah', 'Opsional, isi Ya/Tidak atau Hidup/Meninggal.'], + ['Nama Ibu', 'Opsional.'], + ['NIK Ibu', 'Opsional.'], + ['Tahun Lahir Ibu', 'Opsional, angka 4 digit.'], + ['Pendidikan Ibu', 'Opsional.'], + ['Pekerjaan Ibu', 'Opsional.'], + ['No. HP Ibu', 'Opsional.'], + ['Alamat Ibu', 'Opsional.'], + ['Status Hidup Ibu', 'Opsional, isi Ya/Tidak atau Hidup/Meninggal.'], + ['Nama Wali', 'Opsional.'], + ['NIK Wali', 'Opsional.'], + ['Tahun Lahir Wali', 'Opsional, angka 4 digit.'], + ['Pendidikan Wali', 'Opsional.'], + ['Pekerjaan Wali', 'Opsional.'], + ['No. HP Wali', 'Opsional.'], + ['Alamat Wali', 'Opsional.'], + ['Status Hidup Wali', 'Opsional, isi Ya/Tidak atau Hidup/Meninggal.'], ], null, 'A1'); foreach (range('A', 'B') as $column) { $guideSheet->getColumnDimension($column)->setAutoSize(true); @@ -526,11 +675,26 @@ class StudentController extends Controller ]); } + public function downloadImportExample() + { + $this->ensureAdmin(); + + $path = storage_path('app/private/student-import-example.xlsx'); + + abort_unless(file_exists($path), 404, 'File contoh import siswa belum tersedia.'); + + return response()->file($path, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition' => 'attachment; filename="contoh-import-siswa.xlsx"', + ]); + } + protected function validateStudent(Request $request, ?Student $student = null): array { $data = $request->validate([ 'student_number' => ['nullable', 'string', 'max:30', Rule::unique('students', 'student_number')->ignore($student?->id)], 'nisn' => ['nullable', 'string', 'max:30', Rule::unique('students', 'nisn')->ignore($student?->id)], + 'nik' => ['nullable', 'string', 'max:30', Rule::unique('students', 'nik')->ignore($student?->id)], 'full_name' => ['required', 'string', 'max:150'], 'gender' => ['required', Rule::in(['L', 'P'])], 'place_of_birth' => ['nullable', 'string', 'max:100'], @@ -598,16 +762,22 @@ class StudentController extends Controller return $data; } - protected function formData(): array + protected function formData(?Student $student = null): array { + $classOptionsQuery = SchoolClass::query() + ->when( + $student?->class_id, + fn ($query) => $query->where('is_active', true)->orWhere('id', $student->class_id), + fn ($query) => $query->where('is_active', true) + ) + ->orderBy('name'); + return [ 'academicYears' => AcademicYear::where('is_active', true)->orderBy('name')->pluck('name', 'id'), 'departments' => Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'), 'classes' => SchoolClass::where('is_active', true)->orderBy('name')->pluck('name', 'id'), 'provinces' => Province::orderBy('name')->pluck('name', 'id'), - 'classOptions' => SchoolClass::query() - ->where('is_active', true) - ->orderBy('name') + 'classOptions' => $classOptionsQuery ->get(['id', 'name', 'department_id', 'academic_year_id']) ->map(fn ($classRoom) => [ 'id' => $classRoom->id, @@ -825,6 +995,48 @@ class StudentController extends Controller $graduationRecord->ijazah_file_size = $file->getSize(); } + if (request()->hasFile('transcript_file')) { + if ($graduationRecord->transcript_file_path && Storage::disk('public')->exists($graduationRecord->transcript_file_path)) { + Storage::disk('public')->delete($graduationRecord->transcript_file_path); + } + + $file = request()->file('transcript_file'); + $path = $file->store("student-graduation-documents/{$student->id}/transcript", 'public'); + + $graduationRecord->transcript_file_name = $file->getClientOriginalName(); + $graduationRecord->transcript_file_path = $path; + $graduationRecord->transcript_mime_type = $file->getMimeType(); + $graduationRecord->transcript_file_size = $file->getSize(); + } + + if (request()->hasFile('ukk_certificate_file')) { + if ($graduationRecord->ukk_certificate_file_path && Storage::disk('public')->exists($graduationRecord->ukk_certificate_file_path)) { + Storage::disk('public')->delete($graduationRecord->ukk_certificate_file_path); + } + + $file = request()->file('ukk_certificate_file'); + $path = $file->store("student-graduation-documents/{$student->id}/ukk-certificate", 'public'); + + $graduationRecord->ukk_certificate_file_name = $file->getClientOriginalName(); + $graduationRecord->ukk_certificate_file_path = $path; + $graduationRecord->ukk_certificate_mime_type = $file->getMimeType(); + $graduationRecord->ukk_certificate_file_size = $file->getSize(); + } + + if (request()->hasFile('lsp_certificate_file')) { + if ($graduationRecord->lsp_certificate_file_path && Storage::disk('public')->exists($graduationRecord->lsp_certificate_file_path)) { + Storage::disk('public')->delete($graduationRecord->lsp_certificate_file_path); + } + + $file = request()->file('lsp_certificate_file'); + $path = $file->store("student-graduation-documents/{$student->id}/lsp-certificate", 'public'); + + $graduationRecord->lsp_certificate_file_name = $file->getClientOriginalName(); + $graduationRecord->lsp_certificate_file_path = $path; + $graduationRecord->lsp_certificate_mime_type = $file->getMimeType(); + $graduationRecord->lsp_certificate_file_size = $file->getSize(); + } + $graduationRecord->save(); return; @@ -838,14 +1050,54 @@ class StudentController extends Controller return [ 'student_number' => ['nullable', 'string', 'max:30'], 'nisn' => ['nullable', 'string', 'max:30'], + 'nik' => ['nullable', 'string', 'max:30'], 'full_name' => ['required', 'string', 'max:150'], 'gender' => ['required', Rule::in(['L', 'P'])], + 'place_of_birth' => ['nullable', 'string', 'max:100'], + 'date_of_birth' => ['nullable', 'date'], + 'religion' => ['nullable', 'string', 'max:50'], + 'nationality' => ['nullable', 'string', 'max:50'], + 'family_card_number' => ['nullable', 'string', 'max:30'], + 'child_order' => ['nullable', 'integer', 'min:1'], + 'number_of_siblings' => ['nullable', 'integer', 'min:0'], + 'phone_number' => ['nullable', 'string', 'max:30'], + 'email' => ['nullable', 'email', 'max:100'], + 'address' => ['nullable', 'string'], + 'province_id' => ['nullable', 'string', 'exists:provinces,id'], + 'regency_id' => ['nullable', 'string', 'exists:regencies,id'], + 'district_id' => ['nullable', 'string', 'exists:districts,id'], + 'village_id' => ['nullable', 'string', 'exists:villages,id'], + 'postal_code' => ['nullable', 'string', 'max:10'], 'academic_year_id' => ['required', 'integer', Rule::exists('academic_years', 'id')->where(fn ($query) => $query->where('is_active', true))], 'department_id' => ['required', 'integer', Rule::exists('departments', 'id')->where(fn ($query) => $query->where('is_active', true))], 'class_id' => ['required', 'integer', Rule::exists('classes', 'id')->where(fn ($query) => $query->where('is_active', true))], 'entry_date' => ['nullable', 'date'], 'status' => ['required', Rule::in(array_keys($this->statusOptions()))], - 'phone_number' => ['nullable', 'string', 'max:30'], + 'notes' => ['nullable', 'string'], + 'parents.father.name' => ['nullable', 'string', 'max:150'], + 'parents.father.nik' => ['nullable', 'string', 'max:30'], + 'parents.father.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year], + 'parents.father.education' => ['nullable', 'string', 'max:100'], + 'parents.father.occupation' => ['nullable', 'string', 'max:100'], + 'parents.father.phone_number' => ['nullable', 'string', 'max:30'], + 'parents.father.address' => ['nullable', 'string'], + 'parents.father.is_alive' => ['nullable', 'boolean'], + 'parents.mother.name' => ['nullable', 'string', 'max:150'], + 'parents.mother.nik' => ['nullable', 'string', 'max:30'], + 'parents.mother.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year], + 'parents.mother.education' => ['nullable', 'string', 'max:100'], + 'parents.mother.occupation' => ['nullable', 'string', 'max:100'], + 'parents.mother.phone_number' => ['nullable', 'string', 'max:30'], + 'parents.mother.address' => ['nullable', 'string'], + 'parents.mother.is_alive' => ['nullable', 'boolean'], + 'parents.guardian.name' => ['nullable', 'string', 'max:150'], + 'parents.guardian.nik' => ['nullable', 'string', 'max:30'], + 'parents.guardian.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year], + 'parents.guardian.education' => ['nullable', 'string', 'max:100'], + 'parents.guardian.occupation' => ['nullable', 'string', 'max:100'], + 'parents.guardian.phone_number' => ['nullable', 'string', 'max:30'], + 'parents.guardian.address' => ['nullable', 'string'], + 'parents.guardian.is_alive' => ['nullable', 'boolean'], ]; } @@ -854,14 +1106,54 @@ class StudentController extends Controller return [ 'student_number' => 'NIS', 'nisn' => 'NISN', + 'nik' => 'NIK', 'full_name' => 'nama lengkap', 'gender' => 'jenis kelamin', + 'place_of_birth' => 'tempat lahir', + 'date_of_birth' => 'tanggal lahir', + 'religion' => 'agama', + 'nationality' => 'kewarganegaraan', + 'family_card_number' => 'nomor kartu keluarga', + 'child_order' => 'anak ke', + 'number_of_siblings' => 'jumlah saudara', + 'phone_number' => 'nomor HP', + 'email' => 'email', + 'address' => 'alamat', + 'province_id' => 'provinsi', + 'regency_id' => 'kabupaten/kota', + 'district_id' => 'kecamatan', + 'village_id' => 'kelurahan/desa', + 'postal_code' => 'kode pos', 'academic_year_id' => 'tahun ajaran', 'department_id' => 'program keahlian', 'class_id' => 'kelas', 'entry_date' => 'tanggal masuk', 'status' => 'status', - 'phone_number' => 'nomor HP', + 'notes' => 'catatan', + 'parents.father.name' => 'nama ayah', + 'parents.father.nik' => 'NIK ayah', + 'parents.father.birth_year' => 'tahun lahir ayah', + 'parents.father.education' => 'pendidikan ayah', + 'parents.father.occupation' => 'pekerjaan ayah', + 'parents.father.phone_number' => 'nomor HP ayah', + 'parents.father.address' => 'alamat ayah', + 'parents.father.is_alive' => 'status hidup ayah', + 'parents.mother.name' => 'nama ibu', + 'parents.mother.nik' => 'NIK ibu', + 'parents.mother.birth_year' => 'tahun lahir ibu', + 'parents.mother.education' => 'pendidikan ibu', + 'parents.mother.occupation' => 'pekerjaan ibu', + 'parents.mother.phone_number' => 'nomor HP ibu', + 'parents.mother.address' => 'alamat ibu', + 'parents.mother.is_alive' => 'status hidup ibu', + 'parents.guardian.name' => 'nama wali', + 'parents.guardian.nik' => 'NIK wali', + 'parents.guardian.birth_year' => 'tahun lahir wali', + 'parents.guardian.education' => 'pendidikan wali', + 'parents.guardian.occupation' => 'pekerjaan wali', + 'parents.guardian.phone_number' => 'nomor HP wali', + 'parents.guardian.address' => 'alamat wali', + 'parents.guardian.is_alive' => 'status hidup wali', ]; } @@ -968,30 +1260,75 @@ class StudentController extends Controller $studentNumber = $this->nullIfBlank($normalized['student_number'] ?? ($normalized['nis'] ?? null)); $nisn = $this->nullIfBlank($normalized['nisn'] ?? null); + $nik = $this->nullIfBlank($normalized['nik'] ?? null); $fullName = $this->nullIfBlank($normalized['full_name'] ?? ($normalized['nama_lengkap'] ?? null)); $gender = strtoupper((string) $this->nullIfBlank($normalized['gender'] ?? ($normalized['jenis_kelamin_l_p'] ?? null))); - $status = strtolower((string) ($this->nullIfBlank($normalized['status'] ?? null) ?? 'active')); - $entryDate = $this->nullIfBlank($normalized['entry_date'] ?? ($normalized['tanggal_masuk_yyyy_mm_dd'] ?? null)); + $placeOfBirth = $this->nullIfBlank($normalized['place_of_birth'] ?? ($normalized['tempat_lahir'] ?? null)); + $dateOfBirth = $this->normalizeImportDate($normalized['date_of_birth'] ?? ($normalized['tanggal_lahir_yyyy_mm_dd'] ?? null)); + $religion = $this->nullIfBlank($normalized['religion'] ?? ($normalized['agama'] ?? null)); + $nationality = $this->nullIfBlank($normalized['nationality'] ?? ($normalized['kewarganegaraan'] ?? null)); + $familyCardNumber = $this->nullIfBlank($normalized['family_card_number'] ?? ($normalized['no_kartu_keluarga'] ?? null)); + $childOrder = $this->normalizeImportInteger($normalized['child_order'] ?? ($normalized['anak_ke'] ?? null)); + $numberOfSiblings = $this->normalizeImportInteger($normalized['number_of_siblings'] ?? ($normalized['jumlah_saudara'] ?? null)); $phoneNumber = $this->nullIfBlank($normalized['phone_number'] ?? ($normalized['nomor_hp'] ?? null)); + $email = $this->nullIfBlank($normalized['email'] ?? null); + $address = $this->nullIfBlank($normalized['address'] ?? ($normalized['alamat'] ?? null)); + $postalCode = $this->nullIfBlank($normalized['postal_code'] ?? ($normalized['kode_pos'] ?? null)); + $status = strtolower((string) ($this->nullIfBlank($normalized['status'] ?? null) ?? 'active')); + $entryDate = $this->normalizeImportDate($normalized['entry_date'] ?? ($normalized['tanggal_masuk_yyyy_mm_dd'] ?? null)); + $notes = $this->nullIfBlank($normalized['notes'] ?? ($normalized['catatan'] ?? null)); $academicYearSource = $this->nullIfBlank($normalized['academic_year_id'] ?? ($normalized['tahun_ajaran'] ?? null)); $departmentSource = $this->nullIfBlank($normalized['department_id'] ?? ($normalized['program_keahlian'] ?? null)); $classSource = $this->nullIfBlank($normalized['class_id'] ?? ($normalized['kelas'] ?? null)); + $provinceSource = $this->nullIfBlank($normalized['province_id'] ?? ($normalized['provinsi'] ?? null)); + $regencySource = $this->nullIfBlank($normalized['regency_id'] ?? ($normalized['kabupaten_kota'] ?? null)); + $districtSource = $this->nullIfBlank($normalized['district_id'] ?? ($normalized['kecamatan'] ?? null)); + $villageSource = $this->nullIfBlank($normalized['village_id'] ?? ($normalized['kelurahan_desa'] ?? null)); $academicYearId = $this->resolveAcademicYearId($academicYearSource); $departmentId = $this->resolveDepartmentId($departmentSource); $classId = $this->resolveClassId($classSource, $academicYearId, $departmentId); + $provinceId = $this->resolveProvinceId($provinceSource); + $regencyId = $this->resolveRegencyId($regencySource, $provinceId); + $districtId = $this->resolveDistrictId($districtSource, $regencyId); + $villageId = $this->resolveVillageId($villageSource, $districtId); return [ 'student_number' => $studentNumber, 'nisn' => $nisn, + 'nik' => $nik, 'full_name' => $fullName, 'gender' => $gender, + 'place_of_birth' => $placeOfBirth, + 'date_of_birth' => $dateOfBirth, + 'religion' => $religion, + 'nationality' => $nationality, + 'family_card_number' => $familyCardNumber, + 'child_order' => $childOrder, + 'number_of_siblings' => $numberOfSiblings, + 'phone_number' => $phoneNumber, + 'email' => $email, + 'address' => $address, + 'province_id' => $provinceId, + 'regency_id' => $regencyId, + 'district_id' => $districtId, + 'village_id' => $villageId, + 'province' => $provinceId ? Province::where('id', $provinceId)->value('name') : null, + 'city' => $regencyId ? Regency::where('id', $regencyId)->value('name') : null, + 'district' => $districtId ? District::where('id', $districtId)->value('name') : null, + 'village' => $villageId ? Village::where('id', $villageId)->value('name') : null, + 'postal_code' => $postalCode, 'academic_year_id' => $academicYearId, 'department_id' => $departmentId, 'class_id' => $classId, 'entry_date' => $entryDate, 'status' => $status, - 'phone_number' => $phoneNumber, + 'notes' => $notes, + 'parents' => [ + 'father' => $this->normalizeImportParentData($normalized, 'father', 'ayah'), + 'mother' => $this->normalizeImportParentData($normalized, 'mother', 'ibu'), + 'guardian' => $this->normalizeImportParentData($normalized, 'guardian', 'wali'), + ], ]; } @@ -1038,6 +1375,181 @@ class StudentController extends Controller ->value('id'); } + protected function resolveProvinceId(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return (string) $value; + } + + return Province::where('name', trim((string) $value))->value('id'); + } + + protected function resolveRegencyId(mixed $value, mixed $provinceId = null): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return (string) $value; + } + + return Regency::query() + ->when($provinceId, fn ($query) => $query->where('province_id', $provinceId)) + ->where('name', trim((string) $value)) + ->value('id'); + } + + protected function resolveDistrictId(mixed $value, mixed $regencyId = null): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return (string) $value; + } + + return District::query() + ->when($regencyId, fn ($query) => $query->where('regency_id', $regencyId)) + ->where('name', trim((string) $value)) + ->value('id'); + } + + protected function resolveVillageId(mixed $value, mixed $districtId = null): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return (string) $value; + } + + return Village::query() + ->when($districtId, fn ($query) => $query->where('district_id', $districtId)) + ->where('name', trim((string) $value)) + ->value('id'); + } + + protected function normalizeImportDate(mixed $value): mixed + { + $value = $this->nullIfBlank($value); + + if ($value === null) { + return null; + } + + if (is_numeric($value)) { + try { + return \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject((float) $value)->format('Y-m-d'); + } catch (\Throwable) { + return $value; + } + } + + return $value; + } + + protected function normalizeImportInteger(mixed $value): mixed + { + $value = $this->nullIfBlank($value); + + if ($value === null) { + return null; + } + + if (is_numeric($value)) { + return (int) $value; + } + + return $value; + } + + protected function normalizeImportBoolean(mixed $value): ?bool + { + $value = $this->nullIfBlank($value); + + if ($value === null) { + return null; + } + + if (is_bool($value)) { + return $value; + } + + $normalized = strtolower(trim((string) $value)); + + return match ($normalized) { + '1', 'true', 'ya', 'y', 'yes', 'hidup', 'masih hidup' => true, + '0', 'false', 'tidak', 't', 'no', 'meninggal', 'wafat' => false, + default => null, + }; + } + + protected function normalizeImportParentData(array $normalized, string $relationKey, string $legacyPrefix): array + { + return [ + 'name' => $this->nullIfBlank($normalized["parent_{$relationKey}_name"] ?? ($normalized["nama_{$legacyPrefix}"] ?? null)), + 'nik' => $this->nullIfBlank($normalized["parent_{$relationKey}_nik"] ?? ($normalized["nik_{$legacyPrefix}"] ?? null)), + 'birth_year' => $this->normalizeImportInteger($normalized["parent_{$relationKey}_birth_year"] ?? ($normalized["tahun_lahir_{$legacyPrefix}"] ?? null)), + 'education' => $this->nullIfBlank($normalized["parent_{$relationKey}_education"] ?? ($normalized["pendidikan_{$legacyPrefix}"] ?? null)), + 'occupation' => $this->nullIfBlank($normalized["parent_{$relationKey}_occupation"] ?? ($normalized["pekerjaan_{$legacyPrefix}"] ?? null)), + 'phone_number' => $this->nullIfBlank($normalized["parent_{$relationKey}_phone_number"] ?? ($normalized["no_hp_{$legacyPrefix}"] ?? null)), + 'address' => $this->nullIfBlank($normalized["parent_{$relationKey}_address"] ?? ($normalized["alamat_{$legacyPrefix}"] ?? null)), + 'is_alive' => $this->normalizeImportBoolean($normalized["parent_{$relationKey}_is_alive"] ?? ($normalized["status_hidup_{$legacyPrefix}"] ?? null)), + ]; + } + + protected function syncImportedParents(Student $student, array $parents): void + { + foreach (['father', 'mother', 'guardian'] as $relationType) { + $parentData = $parents[$relationType] ?? []; + $hasMeaningfulValue = collect($parentData) + ->except('is_alive') + ->filter(fn ($value) => filled($value)) + ->isNotEmpty(); + + if (! $hasMeaningfulValue && ! array_key_exists('is_alive', $parentData)) { + continue; + } + + $existingParent = $student->parents()->where('relation_type', $relationType)->first(); + + if (! $hasMeaningfulValue) { + if ($existingParent) { + $existingParent->delete(); + } + + continue; + } + + $payload = [ + 'name' => $parentData['name'] ?? '-', + 'nik' => $parentData['nik'] ?? null, + 'birth_year' => $parentData['birth_year'] ?? null, + 'education' => $parentData['education'] ?? null, + 'occupation' => $parentData['occupation'] ?? null, + 'phone_number' => $parentData['phone_number'] ?? null, + 'address' => $parentData['address'] ?? null, + 'is_alive' => $parentData['is_alive'] ?? false, + ]; + + if ($existingParent) { + $existingParent->update($payload); + } else { + $student->parents()->create([ + 'relation_type' => $relationType, + ...$payload, + ]); + } + } + } + protected function parseImportFile(string $path, string $originalName): array { $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); diff --git a/app/Http/Controllers/StudentExitController.php b/app/Http/Controllers/StudentExitController.php index 086069d..d73e078 100644 --- a/app/Http/Controllers/StudentExitController.php +++ b/app/Http/Controllers/StudentExitController.php @@ -21,7 +21,7 @@ class StudentExitController extends Controller $departmentId = $request->get('department_id'); $classId = $request->get('class_id'); - $studentExits = StudentExit::query() + $studentExitsQuery = StudentExit::query() ->with(['academicYear', 'department', 'classRoom']) ->when($search !== '', function ($query) use ($search) { $query->where(function ($subQuery) use ($search) { @@ -32,12 +32,25 @@ class StudentExitController extends Controller }) ->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId)) ->when($departmentId, fn ($query) => $query->where('department_id', $departmentId)) - ->when($classId, fn ($query) => $query->where('class_id', $classId)) + ->when($classId, fn ($query) => $query->where('class_id', $classId)); + + $studentExits = (clone $studentExitsQuery) ->latest('exit_date') ->latest('id') ->paginate(10) ->withQueryString(); + $totalExits = StudentExit::count(); + $withLetterCount = StudentExit::query()->whereNotNull('exit_letter_file_path')->count(); + $withoutLetterCount = max($totalExits - $withLetterCount, 0); + $thisYearCount = StudentExit::query()->whereYear('exit_date', now()->year)->count(); + $latestExitDate = StudentExit::query()->max('exit_date'); + $recentExits = StudentExit::query() + ->latest('exit_date') + ->latest('id') + ->limit(3) + ->get(['id', 'full_name', 'exit_date', 'department_id', 'class_id']); + return view('student-exits.index', [ 'studentExits' => $studentExits, 'search' => $search, @@ -49,6 +62,15 @@ class StudentExitController extends Controller 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), 'departments' => Department::orderBy('name')->pluck('name', 'id'), 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), + 'summary' => [ + 'total' => $totalExits, + 'with_letter' => $withLetterCount, + 'without_letter' => $withoutLetterCount, + 'this_year' => $thisYearCount, + 'latest_exit_date' => $latestExitDate, + 'filtered' => $studentExits->total(), + ], + 'recentExits' => $recentExits, ]); } diff --git a/app/Http/Controllers/StudentSelfServiceController.php b/app/Http/Controllers/StudentSelfServiceController.php index b90e1d7..10557fd 100644 --- a/app/Http/Controllers/StudentSelfServiceController.php +++ b/app/Http/Controllers/StudentSelfServiceController.php @@ -158,8 +158,12 @@ class StudentSelfServiceController extends Controller protected function generateUniqueCode(): string { + $characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + do { - $code = strtoupper(Str::random(8)); + $code = collect(range(1, 4)) + ->map(fn () => $characters[random_int(0, strlen($characters) - 1)]) + ->implode(''); } while (Student::where('self_service_access_code', $code)->exists()); return $code; diff --git a/app/Http/Controllers/StudentTransferController.php b/app/Http/Controllers/StudentTransferController.php index 7482fbc..23632a5 100644 --- a/app/Http/Controllers/StudentTransferController.php +++ b/app/Http/Controllers/StudentTransferController.php @@ -21,7 +21,7 @@ class StudentTransferController extends Controller $departmentId = $request->get('department_id'); $classId = $request->get('class_id'); - $studentTransfers = StudentTransfer::query() + $studentTransfersQuery = StudentTransfer::query() ->with(['academicYear', 'department', 'classRoom']) ->when($search !== '', function ($query) use ($search) { $query->where(function ($subQuery) use ($search) { @@ -32,12 +32,25 @@ class StudentTransferController extends Controller }) ->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId)) ->when($departmentId, fn ($query) => $query->where('department_id', $departmentId)) - ->when($classId, fn ($query) => $query->where('class_id', $classId)) + ->when($classId, fn ($query) => $query->where('class_id', $classId)); + + $studentTransfers = (clone $studentTransfersQuery) ->latest('transfer_date') ->latest('id') ->paginate(10) ->withQueryString(); + $totalTransfers = StudentTransfer::count(); + $withLetterCount = StudentTransfer::query()->whereNotNull('transfer_letter_file_path')->count(); + $withoutLetterCount = max($totalTransfers - $withLetterCount, 0); + $thisYearCount = StudentTransfer::query()->whereYear('transfer_date', now()->year)->count(); + $latestTransferDate = StudentTransfer::query()->max('transfer_date'); + $recentTransfers = StudentTransfer::query() + ->latest('transfer_date') + ->latest('id') + ->limit(3) + ->get(['id', 'full_name', 'transfer_date', 'department_id', 'class_id']); + return view('student-transfers.index', [ 'studentTransfers' => $studentTransfers, 'search' => $search, @@ -49,6 +62,15 @@ class StudentTransferController extends Controller 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), 'departments' => Department::orderBy('name')->pluck('name', 'id'), 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), + 'summary' => [ + 'total' => $totalTransfers, + 'with_letter' => $withLetterCount, + 'without_letter' => $withoutLetterCount, + 'this_year' => $thisYearCount, + 'latest_transfer_date' => $latestTransferDate, + 'filtered' => $studentTransfers->total(), + ], + 'recentTransfers' => $recentTransfers, ]); } diff --git a/app/Http/Controllers/SyncTokenController.php b/app/Http/Controllers/SyncTokenController.php index 7b00717..0a4c379 100644 --- a/app/Http/Controllers/SyncTokenController.php +++ b/app/Http/Controllers/SyncTokenController.php @@ -44,14 +44,10 @@ class SyncTokenController extends Controller public function revoke(SyncToken $syncToken): RedirectResponse { - if (! $syncToken->revoked_at) { - $syncToken->update([ - 'revoked_at' => now(), - ]); - } + $syncToken->delete(); return redirect() ->route('settings.sync-tokens.index') - ->with('status', 'Token sinkronisasi berhasil dinonaktifkan.'); + ->with('status', 'Token sinkronisasi berhasil dihapus.'); } } diff --git a/app/Models/Student.php b/app/Models/Student.php index 2e253ef..479acce 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -37,6 +37,7 @@ class Student extends Model protected $fillable = [ 'student_number', 'nisn', + 'nik', 'full_name', 'gender', 'place_of_birth', diff --git a/app/Models/StudentGraduation.php b/app/Models/StudentGraduation.php index d69045c..9a30c78 100644 --- a/app/Models/StudentGraduation.php +++ b/app/Models/StudentGraduation.php @@ -29,6 +29,18 @@ class StudentGraduation extends Model 'ijazah_file_path', 'ijazah_mime_type', 'ijazah_file_size', + 'transcript_file_name', + 'transcript_file_path', + 'transcript_mime_type', + 'transcript_file_size', + 'ukk_certificate_file_name', + 'ukk_certificate_file_path', + 'ukk_certificate_mime_type', + 'ukk_certificate_file_size', + 'lsp_certificate_file_name', + 'lsp_certificate_file_path', + 'lsp_certificate_mime_type', + 'lsp_certificate_file_size', 'status', ]; diff --git a/database/migrations/2026_04_29_202100_add_transcript_and_skhun_to_student_graduations_table.php b/database/migrations/2026_04_29_202100_add_transcript_and_skhun_to_student_graduations_table.php new file mode 100644 index 0000000..b2bf17d --- /dev/null +++ b/database/migrations/2026_04_29_202100_add_transcript_and_skhun_to_student_graduations_table.php @@ -0,0 +1,38 @@ +string('transcript_file_name')->nullable()->after('ijazah_file_size'); + $table->string('transcript_file_path')->nullable()->after('transcript_file_name'); + $table->string('transcript_mime_type')->nullable()->after('transcript_file_path'); + $table->unsignedBigInteger('transcript_file_size')->nullable()->after('transcript_mime_type'); + $table->string('skhun_file_name')->nullable()->after('transcript_file_size'); + $table->string('skhun_file_path')->nullable()->after('skhun_file_name'); + $table->string('skhun_mime_type')->nullable()->after('skhun_file_path'); + $table->unsignedBigInteger('skhun_file_size')->nullable()->after('skhun_mime_type'); + }); + } + + public function down(): void + { + Schema::table('student_graduations', function (Blueprint $table) { + $table->dropColumn([ + 'transcript_file_name', + 'transcript_file_path', + 'transcript_mime_type', + 'transcript_file_size', + 'skhun_file_name', + 'skhun_file_path', + 'skhun_mime_type', + 'skhun_file_size', + ]); + }); + } +}; diff --git a/database/migrations/2026_04_29_202600_add_ukk_and_lsp_certificates_to_student_graduations_table.php b/database/migrations/2026_04_29_202600_add_ukk_and_lsp_certificates_to_student_graduations_table.php new file mode 100644 index 0000000..75ab79f --- /dev/null +++ b/database/migrations/2026_04_29_202600_add_ukk_and_lsp_certificates_to_student_graduations_table.php @@ -0,0 +1,38 @@ +string('ukk_certificate_file_name')->nullable()->after('skhun_file_size'); + $table->string('ukk_certificate_file_path')->nullable()->after('ukk_certificate_file_name'); + $table->string('ukk_certificate_mime_type')->nullable()->after('ukk_certificate_file_path'); + $table->unsignedBigInteger('ukk_certificate_file_size')->nullable()->after('ukk_certificate_mime_type'); + $table->string('lsp_certificate_file_name')->nullable()->after('ukk_certificate_file_size'); + $table->string('lsp_certificate_file_path')->nullable()->after('lsp_certificate_file_name'); + $table->string('lsp_certificate_mime_type')->nullable()->after('lsp_certificate_file_path'); + $table->unsignedBigInteger('lsp_certificate_file_size')->nullable()->after('lsp_certificate_mime_type'); + }); + } + + public function down(): void + { + Schema::table('student_graduations', function (Blueprint $table) { + $table->dropColumn([ + 'ukk_certificate_file_name', + 'ukk_certificate_file_path', + 'ukk_certificate_mime_type', + 'ukk_certificate_file_size', + 'lsp_certificate_file_name', + 'lsp_certificate_file_path', + 'lsp_certificate_mime_type', + 'lsp_certificate_file_size', + ]); + }); + } +}; diff --git a/database/migrations/2026_04_29_223900_drop_skhun_columns_from_student_graduations_table.php b/database/migrations/2026_04_29_223900_drop_skhun_columns_from_student_graduations_table.php new file mode 100644 index 0000000..d0af313 --- /dev/null +++ b/database/migrations/2026_04_29_223900_drop_skhun_columns_from_student_graduations_table.php @@ -0,0 +1,30 @@ +dropColumn([ + 'skhun_file_name', + 'skhun_file_path', + 'skhun_mime_type', + 'skhun_file_size', + ]); + }); + } + + public function down(): void + { + Schema::table('student_graduations', function (Blueprint $table) { + $table->string('skhun_file_name')->nullable()->after('transcript_file_size'); + $table->string('skhun_file_path')->nullable()->after('skhun_file_name'); + $table->string('skhun_mime_type')->nullable()->after('skhun_file_path'); + $table->unsignedBigInteger('skhun_file_size')->nullable()->after('skhun_mime_type'); + }); + } +}; diff --git a/database/migrations/2026_04_29_234500_add_nik_to_students_table.php b/database/migrations/2026_04_29_234500_add_nik_to_students_table.php new file mode 100644 index 0000000..4ac56f6 --- /dev/null +++ b/database/migrations/2026_04_29_234500_add_nik_to_students_table.php @@ -0,0 +1,22 @@ +string('nik', 30)->nullable()->after('nisn'); + }); + } + + public function down(): void + { + Schema::table('students', function (Blueprint $table) { + $table->dropColumn('nik'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index bffac96..543e6d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@fortawesome/fontawesome-free": "^7.2.0", "sweetalert2": "^11.26.24" }, "devDependencies": { @@ -67,6 +68,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", + "integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index 4759f08..f67cfe1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "vite": "^8.0.0" }, "dependencies": { + "@fortawesome/fontawesome-free": "^7.2.0", "sweetalert2": "^11.26.24" } } diff --git a/resources/css/app.css b/resources/css/app.css index b5c61c9..90cc5b8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,3 +1,5 @@ +@import '@fortawesome/fontawesome-free/css/all.min.css'; + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/resources/js/app.js b/resources/js/app.js index 92b0f75..e0911aa 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -365,6 +365,82 @@ document.addEventListener('DOMContentLoaded', () => { confirmText: 'Ya, hapus semua', }); + const bulkUploadInput = document.querySelector('[data-bulk-upload-input]'); + const bulkUploadDropzone = document.querySelector('[data-bulk-upload-dropzone]'); + const bulkUploadDropzoneInner = document.querySelector('[data-bulk-upload-dropzone-inner]'); + const bulkUploadSummary = document.querySelector('[data-bulk-upload-summary]'); + const bulkUploadFileList = document.querySelector('[data-bulk-upload-file-list]'); + + if (bulkUploadInput && bulkUploadDropzone && bulkUploadDropzoneInner) { + const renderBulkUploadFiles = () => { + const files = Array.from(bulkUploadInput.files || []); + + if (!bulkUploadSummary || !bulkUploadFileList) { + return; + } + + if (files.length === 0) { + bulkUploadSummary.classList.add('hidden'); + bulkUploadFileList.classList.add('hidden'); + bulkUploadFileList.innerHTML = ''; + return; + } + + const totalBytes = files.reduce((sum, file) => sum + (file.size || 0), 0); + const totalMb = (totalBytes / (1024 * 1024)).toFixed(2); + + bulkUploadSummary.classList.remove('hidden'); + bulkUploadSummary.innerHTML = `
Total Siswa
Jumlah seluruh siswa yang tercatat di sistem saat ini.
Siswa Keluar
Jumlah siswa pada arsip siswa keluar.
Siswa Mutasi
Jumlah siswa pada arsip siswa mutasi.
Siswa Lulus
Jumlah siswa yang sudah berstatus lulus.
@@ -118,24 +106,49 @@Data Siswa
Total siswa aktif yang tersimpan di sistem.
Dokumen Wajib Lengkap
Siswa yang sudah upload seluruh dokumen wajib.
Belum Lengkap
Siswa yang masih kurang upload dokumen wajib.
Belum Tervalidasi Penuh
Siswa yang dokumen wajibnya belum valid semua.
Siswa Berprestasi
Jumlah siswa yang sudah memiliki minimal satu data prestasi.
@@ -222,55 +225,105 @@Aturan nama file yang disarankan
-2024001.pdf9988776655.pdf+ Upload banyak dokumen sekaligus untuk siswa lulus dengan pencocokan otomatis berbasis NIS atau NISN, supaya proses arsip jauh lebih cepat dan minim input manual. +
+