get('search')); $academicYearId = $request->get('academic_year_id'); $departmentId = $request->get('department_id'); $classId = $request->get('class_id'); $statusFilter = $request->get('status'); $viewMode = (string) $request->get('view', 'all'); $viewConfig = $this->resolveStudentViewConfig($viewMode, null); $status = $viewConfig['status']; $sort = (string) $request->get('sort', 'created_at'); $direction = strtolower((string) $request->get('direction', 'desc')) === 'asc' ? 'asc' : 'desc'; $allowedSorts = [ 'full_name' => 'students.full_name', 'student_number' => 'students.student_number', 'academic_year' => 'academic_years.name', 'department' => 'departments.name', 'class' => 'classes.name', 'phone_number' => 'students.phone_number', 'status' => 'students.status', 'created_at' => 'students.created_at', ]; if (! array_key_exists($sort, $allowedSorts)) { $sort = 'created_at'; } $requiredDocumentTypeIds = DocumentType::where('is_active', true)->where('is_required', true)->pluck('id'); $students = Student::query() ->select('students.*') ->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') ->when($search !== '', function ($query) use ($search) { $query->where(function ($subQuery) use ($search) { $subQuery->where('students.full_name', 'like', "%{$search}%") ->orWhere('students.student_number', 'like', "%{$search}%") ->orWhere('students.nisn', 'like', "%{$search}%") ->orWhere('students.phone_number', 'like', "%{$search}%"); }); }) ->when($academicYearId, fn ($query) => $query->where('students.academic_year_id', $academicYearId)) ->when($departmentId, fn ($query) => $query->where('students.department_id', $departmentId)) ->when($classId, fn ($query) => $query->where('students.class_id', $classId)) ->when($viewConfig['view'] === 'all' && blank($statusFilter), fn ($query) => $query->whereNotIn('students.status', ['dropped', 'transferred'])) ->when($viewConfig['view'] === 'all' && $statusFilter === 'active', fn ($query) => $query->where('students.status', 'active')) ->when($viewConfig['view'] === 'all' && $statusFilter === 'non_active', fn ($query) => $query->whereIn('students.status', ['inactive', 'graduated', 'dropped', 'transferred'])) ->when($viewConfig['view'] !== 'all' && $status, fn ($query) => $query->where('students.status', $status)) ->orderBy($allowedSorts[$sort], $direction) ->orderBy('students.id', $direction) ->paginate(10) ->withQueryString(); $students->getCollection()->transform(function ($student) use ($requiredDocumentTypeIds) { $requiredDocuments = $student->documents->whereIn('document_type_id', $requiredDocumentTypeIds); $photoDocument = $student->documents ->filter(function ($document) { $code = strtoupper((string) ($document->documentType?->code ?? '')); $name = strtolower((string) ($document->documentType?->name ?? '')); return $code === 'PAS_FOTO' || str_contains($name, 'pas foto') || str_contains($name, 'pasfoto'); }) ->sortByDesc('created_at') ->first(); $presentRequired = $requiredDocuments ->pluck('document_type_id') ->unique() ->count(); $verifiedRequired = $requiredDocuments ->where('verification_status', 'valid') ->pluck('document_type_id') ->unique() ->count(); $student->required_documents_total = $requiredDocumentTypeIds->count(); $student->required_documents_present = $presentRequired; $student->required_documents_verified = $verifiedRequired; $student->required_documents_missing = max($student->required_documents_total - $presentRequired, 0); $student->required_documents_unverified = max($student->required_documents_present - $student->required_documents_verified, 0); $student->document_completion_percentage = $student->required_documents_total > 0 ? (int) round(($presentRequired / $student->required_documents_total) * 100) : 100; $student->document_verification_percentage = $student->required_documents_total > 0 ? (int) round(($verifiedRequired / $student->required_documents_total) * 100) : 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; }); return view('students.index', [ 'students' => $students, 'search' => $search, 'filters' => [ 'academic_year_id' => $academicYearId, 'department_id' => $departmentId, 'class_id' => $classId, 'status' => $statusFilter, ], 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), 'departments' => Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'), 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), 'statuses' => [ 'active' => 'Aktif', 'non_active' => 'Non Aktif', ], 'studentStatusLabels' => $this->statusOptions(), 'sort' => $sort, 'direction' => $direction, 'pageTitle' => $viewConfig['title'], 'pageDescription' => $viewConfig['description'], 'viewMode' => $viewConfig['view'], ]); } public function create(): View { return view('students.create', $this->formData()); } public function store(Request $request): RedirectResponse { $data = $this->validateStudent($request); $student = Student::create($data); $this->syncAcademicHistory($student, $data); $this->syncExitRecord($student, $data); $this->syncTransferRecord($student, $data); Alert::success('Berhasil', 'Data siswa berhasil ditambahkan.'); return redirect()->route('students.index')->with('success', 'Data siswa berhasil ditambahkan.'); } public function show(Student $student): View { $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) ->orderBy('name') ->get(); $requiredDocuments = $student->documents->whereIn('document_type_id', $requiredDocumentTypes->pluck('id')); $uploadedRequiredDocumentTypeIds = $requiredDocuments ->pluck('document_type_id') ->unique(); $verifiedRequiredDocumentTypeIds = $requiredDocuments ->where('verification_status', 'valid') ->pluck('document_type_id') ->unique(); $requiredDocumentSummary = [ 'total' => $requiredDocumentTypes->count(), 'uploaded' => $uploadedRequiredDocumentTypeIds->count(), 'verified' => $verifiedRequiredDocumentTypeIds->count(), 'missing' => max($requiredDocumentTypes->count() - $uploadedRequiredDocumentTypeIds->count(), 0), 'unverified' => max($uploadedRequiredDocumentTypeIds->count() - $verifiedRequiredDocumentTypeIds->count(), 0), 'percentage' => $requiredDocumentTypes->count() > 0 ? (int) round(($uploadedRequiredDocumentTypeIds->count() / $requiredDocumentTypes->count()) * 100) : 100, 'verification_percentage' => $requiredDocumentTypes->count() > 0 ? (int) round(($verifiedRequiredDocumentTypeIds->count() / $requiredDocumentTypes->count()) * 100) : 100, 'missing_items' => $requiredDocumentTypes ->whereNotIn('id', $uploadedRequiredDocumentTypeIds) ->pluck('name'), 'unverified_items' => $requiredDocumentTypes ->whereIn('id', $uploadedRequiredDocumentTypeIds) ->whereNotIn('id', $verifiedRequiredDocumentTypeIds) ->pluck('name'), ]; return view('students.show', [ 'student' => $student, 'relationLabels' => [ 'father' => 'Ayah', 'mother' => 'Ibu', 'guardian' => 'Wali', ], 'requiredDocumentSummary' => $requiredDocumentSummary, ]); } public function edit(Student $student): View { return view('students.edit', array_merge($this->formData(), [ 'student' => $student, ])); } public function update(Request $request, Student $student): RedirectResponse { $data = $this->validateStudent($request, $student); $student->update($data); $this->syncAcademicHistory($student, $data); $this->syncExitRecord($student, $data); $this->syncTransferRecord($student, $data); Alert::success('Berhasil', 'Data siswa berhasil diperbarui.'); return redirect()->route('students.index')->with('success', 'Data siswa berhasil diperbarui.'); } public function destroy(Student $student): RedirectResponse { $this->ensureAdmin(); $student->delete(); Alert::success('Berhasil', 'Data siswa berhasil dihapus.'); return redirect()->route('students.index')->with('success', 'Data siswa berhasil dihapus.'); } public function bulkDestroy(Request $request): RedirectResponse { $this->ensureAdmin(); $validated = $request->validate([ 'student_ids' => ['required', 'array', 'min:1'], 'student_ids.*' => ['integer', 'exists:students,id'], ], [ 'student_ids.required' => 'Pilih minimal satu siswa untuk dihapus.', ]); $students = Student::whereIn('id', $validated['student_ids'])->get(); $deletedCount = 0; foreach ($students as $student) { $student->delete(); $deletedCount++; } Alert::success('Berhasil', $deletedCount . ' data siswa berhasil dihapus.'); return redirect()->route('students.index')->with('success', $deletedCount . ' data siswa berhasil dihapus.'); } public function importForm(Request $request): View { $this->ensureAdmin(); return view('students.import', array_merge($this->importReferenceData(), [ 'preview' => $request->session()->get('student_import_preview'), ])); } public function previewImport(Request $request): RedirectResponse { $this->ensureAdmin(); $validated = $request->validate([ 'import_file' => ['required', 'file', 'mimes:xlsx,csv,txt'], ], [ 'import_file.mimes' => 'File import harus berformat Excel (.xlsx) atau CSV.', ]); $rows = $this->parseImportFile($validated['import_file']->getRealPath(), $validated['import_file']->getClientOriginalName()); if (count($rows) === 0) { throw ValidationException::withMessages([ 'import_file' => 'File tidak berisi data siswa yang bisa diproses.', ]); } $preview = $this->buildImportPreview($rows); if ($preview['summary']['valid_rows'] === 0) { throw ValidationException::withMessages([ 'import_file' => array_merge(['Tidak ada baris valid yang bisa dipreview.'], array_slice($preview['errors'], 0, 10)), ]); } $request->session()->put('student_import_preview', $preview); return redirect()->route('students.import.form'); } public function import(Request $request): RedirectResponse { $this->ensureAdmin(); $request->validate([ 'preview_token' => ['required', 'string'], ]); $preview = $request->session()->get('student_import_preview'); if (! $preview || ($preview['token'] ?? null) !== $request->string('preview_token')->value()) { throw ValidationException::withMessages([ 'preview_token' => 'Preview import sudah tidak valid. Silakan upload ulang file import.', ]); } $inserted = 0; $updated = 0; DB::transaction(function () use ($preview, &$inserted, &$updated) { foreach ($preview['valid_rows'] as $row) { $payload = Arr::only($row['normalized'], [ 'student_number', 'nisn', 'full_name', 'gender', 'academic_year_id', 'department_id', 'class_id', 'entry_date', 'status', 'phone_number', ]); $lookup = null; if (! empty($payload['student_number'])) { $lookup = Student::withTrashed()->where('student_number', $payload['student_number'])->first(); } if (! $lookup && ! empty($payload['nisn'])) { $lookup = Student::withTrashed()->where('nisn', $payload['nisn'])->first(); } if ($lookup) { if (method_exists($lookup, 'trashed') && $lookup->trashed()) { $lookup->restore(); } $lookup->update($payload); $this->syncAcademicHistory($lookup, $payload); $updated++; continue; } $student = Student::create($payload); $this->syncAcademicHistory($student, $payload); $inserted++; } }); $request->session()->forget('student_import_preview'); Alert::success('Berhasil', "Import data siswa selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui."); return redirect() ->route('students.index') ->with('success', "Import data siswa selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui."); } public function downloadImportTemplate(): Response { $this->ensureAdmin(); $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('Import Siswa'); $headers = [ 'NIS', 'NISN', 'Nama Lengkap', 'Jenis Kelamin (L/P)', 'Tahun Ajaran', 'Program Keahlian', 'Kelas', 'Tanggal Masuk (YYYY-MM-DD)', 'Status', 'Nomor HP', ]; $sheet->fromArray($headers, null, 'A1'); $sheet->fromArray([ '2026-001', '9988776655', 'Contoh Nama Siswa', 'L', 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', ], null, 'A2'); foreach (range('A', 'J') as $column) { $sheet->getColumnDimension($column)->setAutoSize(true); } $referenceSheet = $spreadsheet->createSheet(); $referenceSheet->setTitle('Referensi'); $referenceSheet->fromArray(['Tahun Ajaran'], null, 'A1'); $referenceSheet->fromArray(AcademicYear::orderBy('name')->pluck('name')->all(), null, 'A2'); $referenceSheet->fromArray(['Program Keahlian'], null, 'C1'); $referenceSheet->fromArray(Department::orderBy('name')->pluck('name')->all(), null, 'C2'); $referenceSheet->fromArray(['Kelas'], null, 'E1'); $referenceSheet->fromArray( SchoolClass::with(['academicYear', 'department'])->orderBy('name')->get()->map(function ($classRoom) { return [$classRoom->name, $classRoom->academicYear?->name, $classRoom->department?->name]; })->all(), null, 'E2' ); $referenceSheet->fromArray(['Nama Kelas', 'Tahun Ajaran', 'Program Keahlian'], null, 'E1'); $guideSheet = $spreadsheet->createSheet(); $guideSheet->setTitle('Panduan'); $guideSheet->fromArray([ ['Kolom', 'Keterangan'], ['NIS', 'Boleh kosong, tapi idealnya diisi untuk identitas lokal sekolah.'], ['NISN', 'Boleh kosong jika belum ada.'], ['Nama Lengkap', 'Wajib diisi.'], ['Jenis Kelamin (L/P)', 'Isi L atau P.'], ['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.'], ], null, 'A1'); foreach (range('A', 'B') as $column) { $guideSheet->getColumnDimension($column)->setAutoSize(true); } $writer = new Xlsx($spreadsheet); ob_start(); $writer->save('php://output'); $content = ob_get_clean(); return response($content, 200, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition' => 'attachment; filename="template-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)], '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'], 'village' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'], 'city' => ['nullable', 'string', 'max:100'], 'province' => ['nullable', 'string', 'max:100'], 'postal_code' => ['nullable', 'string', 'max:10'], 'academic_year_id' => ['required', Rule::exists('academic_years', 'id')->where(fn ($query) => $student ? $query->where('is_active', true)->orWhere('id', $student->academic_year_id) : $query->where('is_active', true))], 'department_id' => ['required', Rule::exists('departments', 'id')->where(fn ($query) => $student ? $query->where('is_active', true)->orWhere('id', $student->department_id) : $query->where('is_active', true))], 'class_id' => [ 'required', Rule::exists('classes', 'id')->where(fn ($query) => $student ? $query->where(function ($subQuery) use ($student) { $subQuery->where('is_active', true) ->orWhere('id', $student->class_id); }) : $query->where('is_active', true)), ], 'entry_date' => ['nullable', 'date'], 'status' => ['required', Rule::in(array_keys($this->statusOptions()))], 'notes' => ['nullable', 'string'], 'exit_letter' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png,webp', 'max:5120'], 'transfer_letter' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png,webp', 'max:5120'], ]); $classRoom = SchoolClass::query() ->select(['id', 'academic_year_id', 'department_id']) ->find($data['class_id']); if (! $classRoom) { throw ValidationException::withMessages([ 'class_id' => 'Kelas yang dipilih tidak ditemukan.', ]); } if ((int) $classRoom->academic_year_id !== (int) $data['academic_year_id']) { throw ValidationException::withMessages([ 'class_id' => 'Kelas tidak sesuai dengan Tahun Angkatan yang dipilih.', ]); } if ((int) $classRoom->department_id !== (int) $data['department_id']) { throw ValidationException::withMessages([ 'class_id' => 'Kelas tidak sesuai dengan Program Keahlian yang dipilih.', ]); } $data['province'] = !empty($data['province_id']) ? Province::where('id', $data['province_id'])->value('name') : null; $data['city'] = !empty($data['regency_id']) ? Regency::where('id', $data['regency_id'])->value('name') : null; $data['district'] = !empty($data['district_id']) ? District::where('id', $data['district_id'])->value('name') : null; $data['village'] = !empty($data['village_id']) ? Village::where('id', $data['village_id'])->value('name') : null; return $data; } protected function formData(): array { 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') ->get(['id', 'name', 'department_id', 'academic_year_id']) ->map(fn ($classRoom) => [ 'id' => $classRoom->id, 'name' => $classRoom->name, 'department_id' => $classRoom->department_id, 'academic_year_id' => $classRoom->academic_year_id, ]) ->values(), 'statuses' => $this->statusOptions(), 'religions' => [ 'Islam' => 'Islam', 'Kristen' => 'Kristen', 'Katolik' => 'Katolik', 'Hindu' => 'Hindu', 'Buddha' => 'Buddha', 'Konghucu' => 'Konghucu', ], 'genders' => [ 'L' => 'Laki-laki', 'P' => 'Perempuan', ], ]; } protected function importReferenceData(): array { 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::with(['academicYear', 'department'])->where('is_active', true)->orderBy('name')->get(), 'statuses' => $this->statusOptions(), ]; } protected function statusOptions(): array { return [ 'active' => 'Aktif', 'graduated' => 'Lulus', 'transferred' => 'Pindah', 'dropped' => 'Keluar', 'inactive' => 'Nonaktif', ]; } protected function syncAcademicHistory(Student $student, array $data): void { $currentHistory = StudentAcademicHistory::query() ->where('student_id', $student->id) ->where('is_current', true) ->first(); $samePlacement = $currentHistory && (int) $currentHistory->academic_year_id === (int) $data['academic_year_id'] && (int) $currentHistory->department_id === (int) $data['department_id'] && (int) $currentHistory->class_id === (int) $data['class_id'] && $currentHistory->status === $data['status']; if ($samePlacement) { $currentHistory->update([ 'start_date' => $data['entry_date'] ?? $currentHistory->start_date, ]); return; } StudentAcademicHistory::query() ->where('student_id', $student->id) ->where('is_current', true) ->update([ 'is_current' => false, 'end_date' => now()->toDateString(), ]); StudentAcademicHistory::updateOrCreate( [ 'student_id' => $student->id, 'academic_year_id' => $data['academic_year_id'], ], [ 'department_id' => $data['department_id'], 'class_id' => $data['class_id'], 'status' => $data['status'], 'start_date' => $data['entry_date'] ?? null, 'end_date' => null, 'notes' => null, 'is_current' => true, ] ); } protected function syncExitRecord(Student $student, array $data): void { if (($data['status'] ?? null) === 'dropped') { $exitRecord = StudentExit::firstOrNew(['student_id' => $student->id]); $exitRecord->fill([ 'student_number' => $student->student_number, 'nisn' => $student->nisn, 'full_name' => $student->full_name, 'academic_year_id' => $student->academic_year_id, 'department_id' => $student->department_id, 'class_id' => $student->class_id, 'entry_date' => $student->entry_date, 'exit_date' => $exitRecord->exit_date ?? now()->toDateString(), 'exit_reason' => $student->notes, 'status' => 'dropped', ]); if (request()->hasFile('exit_letter')) { if ($exitRecord->exit_letter_file_path && Storage::disk('public')->exists($exitRecord->exit_letter_file_path)) { Storage::disk('public')->delete($exitRecord->exit_letter_file_path); } $file = request()->file('exit_letter'); $path = $file->store("student-exit-letters/{$student->id}", 'public'); $exitRecord->exit_letter_file_name = $file->getClientOriginalName(); $exitRecord->exit_letter_file_path = $path; $exitRecord->exit_letter_mime_type = $file->getMimeType(); $exitRecord->exit_letter_file_size = $file->getSize(); } $exitRecord->save(); return; } StudentExit::query()->where('student_id', $student->id)->delete(); } protected function syncTransferRecord(Student $student, array $data): void { if (($data['status'] ?? null) === 'transferred') { $transferRecord = StudentTransfer::firstOrNew(['student_id' => $student->id]); $transferRecord->fill([ 'student_number' => $student->student_number, 'nisn' => $student->nisn, 'full_name' => $student->full_name, 'academic_year_id' => $student->academic_year_id, 'department_id' => $student->department_id, 'class_id' => $student->class_id, 'entry_date' => $student->entry_date, 'transfer_date' => $transferRecord->transfer_date ?? now()->toDateString(), 'transfer_reason' => $student->notes, 'status' => 'transferred', ]); if (request()->hasFile('transfer_letter')) { if ($transferRecord->transfer_letter_file_path && Storage::disk('public')->exists($transferRecord->transfer_letter_file_path)) { Storage::disk('public')->delete($transferRecord->transfer_letter_file_path); } $file = request()->file('transfer_letter'); $path = $file->store("student-transfer-letters/{$student->id}", 'public'); $transferRecord->transfer_letter_file_name = $file->getClientOriginalName(); $transferRecord->transfer_letter_file_path = $path; $transferRecord->transfer_letter_mime_type = $file->getMimeType(); $transferRecord->transfer_letter_file_size = $file->getSize(); } $transferRecord->save(); return; } StudentTransfer::query()->where('student_id', $student->id)->delete(); } protected function importRules(): array { return [ 'student_number' => ['nullable', 'string', 'max:30'], 'nisn' => ['nullable', 'string', 'max:30'], 'full_name' => ['required', 'string', 'max:150'], 'gender' => ['required', Rule::in(['L', 'P'])], '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'], ]; } protected function importAttributes(): array { return [ 'student_number' => 'NIS', 'nisn' => 'NISN', 'full_name' => 'nama lengkap', 'gender' => 'jenis kelamin', 'academic_year_id' => 'tahun ajaran', 'department_id' => 'program keahlian', 'class_id' => 'kelas', 'entry_date' => 'tanggal masuk', 'status' => 'status', 'phone_number' => 'nomor HP', ]; } protected function buildImportPreview(array $rows): array { $previewRows = []; $errors = []; $validRows = []; foreach ($rows as $rowNumber => $row) { $normalized = $this->normalizeImportRow($row); if ($this->isImportRowEmpty($normalized)) { continue; } $validator = Validator::make($normalized, $this->importRules(), [], $this->importAttributes()); $validator->after(function ($validator) use ($normalized) { $class = SchoolClass::query()->find($normalized['class_id']); if (! $class) { return; } if ((int) $class->academic_year_id !== (int) $normalized['academic_year_id']) { $validator->errors()->add('class_id', 'Kelas harus berasal dari tahun ajaran yang dipilih.'); } if ((int) $class->department_id !== (int) $normalized['department_id']) { $validator->errors()->add('class_id', 'Kelas harus berasal dari program keahlian yang dipilih.'); } }); $errorMessages = []; $action = 'insert'; if ($validator->fails()) { $errorMessages = $validator->errors()->all(); $errors[] = 'Baris ' . $rowNumber . ': ' . implode(' ', $errorMessages); } else { $lookup = null; $payload = $validator->validated(); if (! empty($payload['student_number'])) { $lookup = Student::withTrashed()->where('student_number', $payload['student_number'])->first(); } if (! $lookup && ! empty($payload['nisn'])) { $lookup = Student::withTrashed()->where('nisn', $payload['nisn'])->first(); } if ($lookup) { $action = 'update'; } $validRows[] = [ 'row_number' => $rowNumber, 'normalized' => $payload, 'action' => $action, ]; } $previewRows[] = [ 'row_number' => $rowNumber, 'data' => $normalized, 'errors' => $errorMessages, 'is_valid' => empty($errorMessages), 'action' => $action, ]; } return [ 'token' => Str::uuid()->toString(), 'rows' => $previewRows, 'valid_rows' => $validRows, 'errors' => $errors, 'summary' => [ 'total_rows' => count($previewRows), 'valid_rows' => count($validRows), 'invalid_rows' => count(array_filter($previewRows, fn ($row) => ! $row['is_valid'])), 'insert_rows' => count(array_filter($previewRows, fn ($row) => $row['is_valid'] && $row['action'] === 'insert')), 'update_rows' => count(array_filter($previewRows, fn ($row) => $row['is_valid'] && $row['action'] === 'update')), ], ]; } protected function normalizeImportRow(array $row): array { $normalized = []; foreach ($row as $key => $value) { $normalizedKey = Str::of((string) $key) ->trim() ->lower() ->replaceMatches('/[^a-z0-9]+/u', '_') ->trim('_') ->value(); $normalized[$normalizedKey] = is_string($value) ? trim($value) : $value; } $studentNumber = $this->nullIfBlank($normalized['student_number'] ?? ($normalized['nis'] ?? null)); $nisn = $this->nullIfBlank($normalized['nisn'] ?? 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)); $phoneNumber = $this->nullIfBlank($normalized['phone_number'] ?? ($normalized['nomor_hp'] ?? 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)); $academicYearId = $this->resolveAcademicYearId($academicYearSource); $departmentId = $this->resolveDepartmentId($departmentSource); $classId = $this->resolveClassId($classSource, $academicYearId, $departmentId); return [ 'student_number' => $studentNumber, 'nisn' => $nisn, 'full_name' => $fullName, 'gender' => $gender, 'academic_year_id' => $academicYearId, 'department_id' => $departmentId, 'class_id' => $classId, 'entry_date' => $entryDate, 'status' => $status, 'phone_number' => $phoneNumber, ]; } protected function resolveAcademicYearId(mixed $value): mixed { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return $value; } return AcademicYear::where('name', trim((string) $value))->value('id'); } protected function resolveDepartmentId(mixed $value): mixed { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return $value; } return Department::where('name', trim((string) $value))->value('id'); } protected function resolveClassId(mixed $value, mixed $academicYearId = null, mixed $departmentId = null): mixed { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return $value; } return SchoolClass::query() ->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId)) ->when($departmentId, fn ($query) => $query->where('department_id', $departmentId)) ->where('name', trim((string) $value)) ->value('id'); } protected function parseImportFile(string $path, string $originalName): array { $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); return match ($extension) { 'xlsx' => $this->parseXlsxRows($path), default => $this->parseCsvRows($path), }; } protected function parseCsvRows(string $path): array { $handle = fopen($path, 'r'); if (! $handle) { return []; } $rows = []; $headers = null; $rowNumber = 1; while (($data = fgetcsv($handle, 0, ',')) !== false) { $rowNumber++; if ($headers === null) { $headers = array_map(fn ($header) => trim((string) $header), $data); continue; } if (count(array_filter($data, fn ($value) => trim((string) $value) !== '')) === 0) { continue; } $rows[$rowNumber] = array_combine($headers, array_pad($data, count($headers), null)); } fclose($handle); return $rows; } protected function parseXlsxRows(string $path): array { $zip = new \ZipArchive(); if ($zip->open($path) !== true) { throw ValidationException::withMessages([ 'import_file' => 'File Excel tidak bisa dibuka.', ]); } $sharedStrings = []; $sharedStringsXml = $zip->getFromName('xl/sharedStrings.xml'); if ($sharedStringsXml) { $xml = simplexml_load_string($sharedStringsXml); if ($xml && isset($xml->si)) { foreach ($xml->si as $item) { $text = ''; if (isset($item->t)) { $text = (string) $item->t; } elseif (isset($item->r)) { foreach ($item->r as $run) { $text .= (string) $run->t; } } $sharedStrings[] = $text; } } } $sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml'); $zip->close(); if (! $sheetXml) { throw ValidationException::withMessages([ 'import_file' => 'Worksheet pertama pada file Excel tidak ditemukan.', ]); } $xml = simplexml_load_string($sheetXml); $xml->registerXPathNamespace('main', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); $rows = []; $headers = null; foreach ($xml->xpath('//main:sheetData/main:row') as $rowIndex => $row) { $values = []; foreach ($row->c as $cell) { $reference = (string) $cell['r']; $column = preg_replace('/\d+/', '', $reference); $index = $this->columnLetterToIndex($column); $type = (string) $cell['t']; $value = ''; if ($type === 'inlineStr' && isset($cell->is->t)) { $value = (string) $cell->is->t; } elseif ($type === 's') { $sharedIndex = (int) ($cell->v ?? 0); $value = $sharedStrings[$sharedIndex] ?? ''; } else { $value = (string) ($cell->v ?? ''); } $values[$index] = $value; } if (empty($values)) { continue; } ksort($values); $ordered = array_values($values); if ($headers === null) { $headers = array_map(fn ($header) => trim((string) $header), $ordered); continue; } $rows[$rowIndex + 1] = array_combine($headers, array_pad($ordered, count($headers), null)); } return $rows; } protected function columnLetterToIndex(string $letters): int { $letters = strtoupper($letters); $index = 0; for ($i = 0; $i < strlen($letters); $i++) { $index = ($index * 26) + (ord($letters[$i]) - 64); } return $index - 1; } protected function isImportRowEmpty(array $row): bool { return count(array_filter($row, fn ($value) => ! is_null($value) && $value !== '')) === 0; } protected function nullIfBlank(mixed $value): mixed { if (is_string($value) && trim($value) === '') { return null; } return $value; } protected function ensureAdmin(): void { abort_unless(auth()->user()?->isAdmin(), 403, 'Aksi ini hanya boleh dilakukan administrator.'); } protected function resolveStudentViewConfig(string $viewMode, mixed $requestedStatus): array { return match ($viewMode) { 'graduated' => [ 'view' => 'graduated', 'status' => 'graduated', 'title' => 'Data Siswa Lulus', 'description' => 'Daftar siswa yang sudah berstatus lulus.', ], 'transferred' => [ 'view' => 'transferred', 'status' => 'transferred', 'title' => 'Data Siswa Mutasi', 'description' => 'Daftar siswa yang berstatus mutasi atau pindah sekolah.', ], 'dropped' => [ 'view' => 'dropped', 'status' => 'dropped', 'title' => 'Data Siswa Keluar', 'description' => 'Daftar siswa yang berstatus keluar dari sekolah.', ], default => [ 'view' => 'all', 'status' => $requestedStatus, 'title' => 'Data Siswa', 'description' => 'Kelola data siswa lengkap berdasarkan master akademik yang tersedia.', ], }; } }