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.nik', '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->where('students.status', 'active')) ->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; }); $summary = [ 'total' => $students->total(), 'byAcademicYear' => Student::query() ->select('academic_years.name', 'academic_years.start_year', DB::raw('count(*) as total')) ->join('academic_years', 'academic_years.id', '=', 'students.academic_year_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.nik', '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)) ->where('students.status', $status ?: 'graduated') ->groupBy('academic_years.name', 'academic_years.start_year') ->orderByDesc('academic_years.start_year') ->limit(3) ->get(), ]; 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::when( $academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId), fn ($query) => $query->forActiveAcademicYear() )->orderBy('name')->pluck('name', 'id'), 'statuses' => $viewConfig['view'] === 'all' ? [ 'active' => 'Aktif', 'non_active' => 'Non Aktif', ] : [], 'studentStatusLabels' => $this->statusOptions(), 'sort' => $sort, 'direction' => $direction, 'pageTitle' => $viewConfig['title'], 'pageDescription' => $viewConfig['description'], 'viewMode' => $viewConfig['view'], 'summary' => $summary, ]); } 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); $this->syncGraduationRecord($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 { $student->loadMissing('classRoom'); return view('students.edit', array_merge( $this->formData($student), [ '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); $this->syncGraduationRecord($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(); $referenceData = $this->importReferenceData(); $preview = $request->session()->get('student_import_preview'); if ($preview) { $preview = $this->hydrateImportPreviewDisplay($preview, $referenceData); } return view('students.import', array_merge($referenceData, [ 'preview' => $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', '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', 'notes', ]); $payload = $this->hydrateRegionNamesFromIds($payload); $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(); } $updatePayload = $this->buildStudentImportUpdatePayload($lookup, $payload); $historyPayload = $this->buildStudentImportHistoryPayload($lookup, $payload); $lookup->update($updatePayload); $this->syncAcademicHistory($lookup, $historyPayload); $this->syncExitRecord($lookup, $historyPayload); $this->syncTransferRecord($lookup, $historyPayload); $this->syncGraduationRecord($lookup, $historyPayload); $this->syncImportedParents($lookup, $row['normalized']['parents'] ?? []); $updated++; continue; } $student = Student::create($payload); $this->syncAcademicHistory($student, $payload); $this->syncExitRecord($student, $payload); $this->syncTransferRecord($student, $payload); $this->syncGraduationRecord($student, $payload); $this->syncImportedParents($student, $row['normalized']['parents'] ?? []); $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() { $this->ensureAdmin(); $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('Import Siswa'); $headers = [ 'NIS', 'NISN', 'NIK', 'Nama Lengkap', 'Jenis Kelamin (L/P)', 'Tempat Lahir', 'Tanggal Lahir (YYYY-MM-DD)', 'Agama', 'Kewarganegaraan', 'Nomor HP', 'Email', 'Alamat', 'Provinsi', 'Kabupaten/Kota', 'Kecamatan', 'Kelurahan/Desa', 'Kode Pos', 'Tahun Ajaran', 'Program Keahlian', 'Kelas', 'Tanggal Masuk (YYYY-MM-DD)', 'Status', 'Catatan', ]; $sheet->fromArray($headers, null, 'A1'); $sheet->fromArray([ '2026-001', '9988776655', '3512345678901234', 'Contoh Nama Siswa', 'L', 'Malang', '2010-01-15', 'Islam', 'Indonesia', '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', 'Catatan opsional', ], null, 'A2'); for ($columnIndex = 1; $columnIndex <= count($headers); $columnIndex++) { $sheet->getColumnDimensionByColumn($columnIndex)->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.'], ['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.'], ['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.'], ], 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"', ]); } public function downloadImportExample() { $this->ensureAdmin(); $path = storage_path('app/private/student-import-example.xlsx'); if (! file_exists($path)) { $response = $this->downloadImportTemplate(); $response->headers->set('Content-Disposition', 'attachment; filename="contoh-import-siswa.xlsx"'); return $response; } 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'], '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(?Student $student = null): array { $classOptionsQuery = SchoolClass::query() ->forActiveAcademicYear() ->when($student?->class_id, fn ($query) => $query->orWhere('id', $student->class_id)) ->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' => $classOptionsQuery ->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 { $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'])->forActiveAcademicYear()->where('is_active', true)->orderBy('name')->get(); return [ 'academicYears' => $academicYears, 'departments' => $departments, 'classes' => $classes, 'statuses' => $this->statusOptions(), 'academicYearsById' => $academicYears->all(), 'departmentsById' => $departments->all(), 'classesById' => $classes->keyBy('id')->map(fn ($classRoom) => [ 'name' => $classRoom->name, 'academic_year' => $classRoom->academicYear?->name, 'department' => $classRoom->department?->name, ])->all(), ]; } protected function hydrateRegionNamesFromIds(array $payload): array { $payload['province'] = ! empty($payload['province_id']) ? Province::where('id', $payload['province_id'])->value('name') : null; $payload['city'] = ! empty($payload['regency_id']) ? Regency::where('id', $payload['regency_id'])->value('name') : null; $payload['district'] = ! empty($payload['district_id']) ? District::where('id', $payload['district_id'])->value('name') : null; $payload['village'] = ! empty($payload['village_id']) ? Village::where('id', $payload['village_id'])->value('name') : null; return $payload; } protected function buildStudentImportUpdatePayload(Student $student, array $payload): array { $preserveWhenBlank = [ 'student_number', 'nisn', 'nik', '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', 'entry_date', 'notes', ]; foreach ($preserveWhenBlank as $field) { if (array_key_exists($field, $payload) && blank($payload[$field])) { $payload[$field] = $student->{$field}; } } return $payload; } protected function buildStudentImportHistoryPayload(Student $student, array $payload): array { $historyPayload = Arr::only($payload, [ 'academic_year_id', 'department_id', 'class_id', 'status', 'entry_date', ]); foreach (['academic_year_id', 'department_id', 'class_id', 'status', 'entry_date'] as $field) { if (! array_key_exists($field, $historyPayload) || blank($historyPayload[$field])) { $historyPayload[$field] = $student->{$field}; } } return $historyPayload; } 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 syncGraduationRecord(Student $student, array $data): void { if (($data['status'] ?? null) === 'graduated') { $graduationRecord = StudentGraduation::firstOrNew(['student_id' => $student->id]); $graduationRecord->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, 'graduation_date' => $graduationRecord->graduation_date ?? now()->toDateString(), 'graduation_note' => $student->notes, 'status' => 'graduated', ]); if (request()->hasFile('skl_file')) { if ($graduationRecord->skl_file_path && Storage::disk('public')->exists($graduationRecord->skl_file_path)) { Storage::disk('public')->delete($graduationRecord->skl_file_path); } $file = request()->file('skl_file'); $path = $file->store("student-graduation-documents/{$student->id}/skl", 'public'); $graduationRecord->skl_file_name = $file->getClientOriginalName(); $graduationRecord->skl_file_path = $path; $graduationRecord->skl_mime_type = $file->getMimeType(); $graduationRecord->skl_file_size = $file->getSize(); } if (request()->hasFile('ijazah_file')) { if ($graduationRecord->ijazah_file_path && Storage::disk('public')->exists($graduationRecord->ijazah_file_path)) { Storage::disk('public')->delete($graduationRecord->ijazah_file_path); } $file = request()->file('ijazah_file'); $path = $file->store("student-graduation-documents/{$student->id}/ijazah", 'public'); $graduationRecord->ijazah_file_name = $file->getClientOriginalName(); $graduationRecord->ijazah_file_path = $path; $graduationRecord->ijazah_mime_type = $file->getMimeType(); $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; } StudentGraduation::query()->where('student_id', $student->id)->delete(); } protected function importRules(): array { 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()))], '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'], ]; } protected function importAttributes(): array { 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', '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', ]; } 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 hydrateImportPreviewDisplay(array $preview, array $referenceData): array { $academicYearsById = $referenceData['academicYearsById'] ?? []; $departmentsById = $referenceData['departmentsById'] ?? []; $classesById = $referenceData['classesById'] ?? []; $statuses = $referenceData['statuses'] ?? []; $preview['rows'] = array_map(function (array $row) use ($academicYearsById, $departmentsById, $classesById, $statuses) { $academicYearId = $row['data']['academic_year_id'] ?? null; $departmentId = $row['data']['department_id'] ?? null; $classId = $row['data']['class_id'] ?? null; $status = $row['data']['status'] ?? null; $classMeta = $classId ? ($classesById[$classId] ?? null) : null; $row['display'] = [ 'academic_year' => $academicYearId ? ($academicYearsById[$academicYearId] ?? 'ID ' . $academicYearId) : '-', 'department' => $departmentId ? ($departmentsById[$departmentId] ?? 'ID ' . $departmentId) : '-', 'class' => $classMeta['name'] ?? ($classId ? 'ID ' . $classId : '-'), 'class_context' => $classMeta ? collect([$classMeta['academic_year'] ?? null, $classMeta['department'] ?? null])->filter()->implode(' · ') : null, 'status' => $status ? ($statuses[$status] ?? strtoupper((string) $status)) : '-', 'province' => $row['data']['province'] ?? '-', 'city' => $row['data']['city'] ?? '-', 'district' => $row['data']['district'] ?? '-', 'village' => $row['data']['village'] ?? '-', ]; return $row; }, $preview['rows'] ?? []); return $preview; } 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); $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))); $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); $provinceName = $provinceId ? Province::where('id', $provinceId)->value('name') : null; $regencyName = $regencyId ? Regency::where('id', $regencyId)->value('name') : null; $districtName = $districtId ? District::where('id', $districtId)->value('name') : null; $villageName = $villageId ? Village::where('id', $villageId)->value('name') : null; 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' => $provinceName, 'city' => $regencyName, 'district' => $districtName, 'village' => $villageName, 'postal_code' => $postalCode, 'academic_year_id' => $academicYearId, 'department_id' => $departmentId, 'class_id' => $classId, 'entry_date' => $entryDate, 'status' => $status, 'notes' => $notes, 'parents' => [ 'father' => $this->normalizeImportParentData($normalized, 'father', 'ayah'), 'mother' => $this->normalizeImportParentData($normalized, 'mother', 'ibu'), 'guardian' => $this->normalizeImportParentData($normalized, 'guardian', 'wali'), ], ]; } 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; } $normalizedValue = trim((string) $value); return Department::query() ->where('name', $normalizedValue) ->orWhere('code', strtoupper($normalizedValue)) ->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 resolveProvinceId(mixed $value): mixed { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (string) $value; } $normalizedValue = $this->normalizeRegionLookupValue((string) $value); return Province::query() ->get(['id', 'name']) ->first(fn ($province) => $this->normalizeRegionLookupValue($province->name) === $normalizedValue) ?->id; } protected function resolveRegencyId(mixed $value, mixed $provinceId = null): mixed { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (string) $value; } $normalizedValue = $this->normalizeRegionLookupValue((string) $value); return Regency::query() ->when($provinceId, fn ($query) => $query->where('province_id', $provinceId)) ->get(['id', 'name']) ->first(function ($regency) use ($normalizedValue) { $candidate = $this->normalizeRegionLookupValue($regency->name); return $candidate === $normalizedValue || $candidate === 'KABUPATEN ' . $normalizedValue || $candidate === 'KOTA ' . $normalizedValue; }) ?->id; } protected function resolveDistrictId(mixed $value, mixed $regencyId = null): mixed { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (string) $value; } $normalizedValue = $this->normalizeRegionLookupValue((string) $value); return District::query() ->when($regencyId, fn ($query) => $query->where('regency_id', $regencyId)) ->get(['id', 'name']) ->first(fn ($district) => $this->normalizeRegionLookupValue($district->name) === $normalizedValue) ?->id; } protected function resolveVillageId(mixed $value, mixed $districtId = null): mixed { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (string) $value; } $normalizedValue = $this->normalizeRegionLookupValue((string) $value); return Village::query() ->when($districtId, fn ($query) => $query->where('district_id', $districtId)) ->get(['id', 'name']) ->first(fn ($village) => $this->normalizeRegionLookupValue($village->name) === $normalizedValue) ?->id; } protected function normalizeRegionLookupValue(string $value): string { $normalized = strtoupper(trim($value)); $normalized = preg_replace('/\s+/', ' ', $normalized) ?? $normalized; $normalized = str_replace(['KAB.', 'KABUPATEN.', 'KOTA.'], ['KABUPATEN', 'KABUPATEN', 'KOTA'], $normalized); return trim($normalized); } 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_int($value)) { return $value; } if (is_float($value)) { return (int) round($value); } if (is_numeric($value)) { return (int) round((float) $value); } if (is_string($value)) { $normalized = trim($value); $normalized = str_replace([',', ' '], '', $normalized); if (preg_match('/^\d+(?:\.0+)?$/', $normalized) === 1) { return (int) round((float) $normalized); } } return $value; } protected function normalizeImportYear(mixed $value): mixed { $value = $this->nullIfBlank($value); if ($value === null) { return null; } if ($value instanceof \DateTimeInterface) { return (int) $value->format('Y'); } if (is_string($value)) { $normalized = trim($value); if (preg_match('/^(\d{4})[-\/]/', $normalized, $matches) === 1) { return (int) $matches[1]; } } if (is_numeric($value)) { $numericValue = (float) $value; $intValue = (int) round($numericValue); if ($intValue >= 1900 && $intValue <= now()->year) { return $intValue; } try { return (int) \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($numericValue)->format('Y'); } catch (\Throwable) { return $intValue; } } return $this->normalizeImportInteger($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->normalizeImportYear($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)); 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); $maxIndex = max(array_keys($values)); $ordered = []; for ($index = 0; $index <= $maxIndex; $index++) { $ordered[$index] = $values[$index] ?? null; } 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) { '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.', ], }; } }