Files
database-kesiswaan/app/Http/Controllers/StudentController.php
T

1962 lines
80 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\AcademicYear;
use App\Models\Department;
use App\Models\District;
use App\Models\Province;
use App\Models\Regency;
use App\Models\SchoolClass;
use App\Models\Student;
use App\Models\Village;
use App\Models\StudentAcademicHistory;
use App\Models\StudentExit;
use App\Models\StudentTransfer;
use App\Models\StudentGraduation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use RealRashid\SweetAlert\Facades\Alert;
use App\Models\DocumentType;
class StudentController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->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::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',
'No. Kartu Keluarga',
'Anak Ke',
'Jumlah Saudara',
'Nomor HP',
'Email',
'Alamat',
'Provinsi',
'Kabupaten/Kota',
'Kecamatan',
'Kelurahan/Desa',
'Kode Pos',
'Tahun Ajaran',
'Program Keahlian',
'Kelas',
'Tanggal Masuk (YYYY-MM-DD)',
'Status',
'Catatan',
'Nama Ayah',
'NIK Ayah',
'Tahun Lahir Ayah',
'Pendidikan Ayah',
'Pekerjaan Ayah',
'No. HP Ayah',
'Alamat Ayah',
'Status Hidup Ayah',
'Nama Ibu',
'NIK Ibu',
'Tahun Lahir Ibu',
'Pendidikan Ibu',
'Pekerjaan Ibu',
'No. HP Ibu',
'Alamat Ibu',
'Status Hidup Ibu',
'Nama Wali',
'NIK Wali',
'Tahun Lahir Wali',
'Pendidikan Wali',
'Pekerjaan Wali',
'No. HP Wali',
'Alamat Wali',
'Status Hidup Wali',
];
$sheet->fromArray($headers, null, 'A1');
$sheet->fromArray([
'2026-001',
'9988776655',
'3512345678901234',
'Contoh Nama Siswa',
'L',
'Malang',
'2010-01-15',
'Islam',
'Indonesia',
'3512345678901235',
'1',
'2',
'081234567890',
'contoh@siswa.local',
'Jl. Contoh No. 1',
'Jawa Timur',
'Kota Malang',
'Klojen',
'Kauman',
'65119',
AcademicYear::orderBy('name')->value('name') ?? '2026/2027',
Department::orderBy('name')->value('name') ?? 'DKV',
SchoolClass::orderBy('name')->value('name') ?? 'X DKV 1',
'2026-07-15',
'active',
'Catatan opsional',
'Budi Santoso',
'3512345678901111',
'1980',
'SMA',
'Wiraswasta',
'081200000001',
'Jl. Ayah No. 1',
'Ya',
'Siti Aminah',
'3512345678902222',
'1982',
'SMA',
'Ibu Rumah Tangga',
'081200000002',
'Jl. Ibu No. 1',
'Ya',
'Ahmad Wali',
'3512345678903333',
'1975',
'S1',
'Pegawai Swasta',
'081200000003',
'Jl. Wali No. 1',
'Ya',
], null, 'A2');
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.'],
['No. Kartu Keluarga', 'Opsional.'],
['Anak Ke', 'Opsional, angka bulat minimal 1.'],
['Jumlah Saudara', 'Opsional, angka bulat minimal 0.'],
['Nomor HP', 'Opsional.'],
['Email', 'Opsional, harus format email valid bila diisi.'],
['Alamat', 'Opsional.'],
['Provinsi', 'Opsional, isi nama persis seperti data master wilayah.'],
['Kabupaten/Kota', 'Opsional, isi nama persis seperti data master wilayah.'],
['Kecamatan', 'Opsional, isi nama persis seperti data master wilayah.'],
['Kelurahan/Desa', 'Opsional, isi nama persis seperti data master wilayah.'],
['Kode Pos', 'Opsional.'],
['Tahun Ajaran', 'Isi dengan nama tahun ajaran persis seperti sheet Referensi.'],
['Program Keahlian', 'Isi dengan nama jurusan persis seperti sheet Referensi.'],
['Kelas', 'Isi dengan nama kelas persis seperti sheet Referensi.'],
['Tanggal Masuk (YYYY-MM-DD)', 'Format contoh: 2026-07-15.'],
['Status', 'Gunakan: active, inactive, transferred, graduated, dropped.'],
['Catatan', 'Opsional.'],
['Nama Ayah', 'Opsional.'],
['NIK Ayah', 'Opsional.'],
['Tahun Lahir Ayah', 'Opsional, angka 4 digit.'],
['Pendidikan Ayah', 'Opsional.'],
['Pekerjaan Ayah', 'Opsional.'],
['No. HP Ayah', 'Opsional.'],
['Alamat Ayah', 'Opsional.'],
['Status Hidup Ayah', 'Opsional, isi Ya/Tidak atau Hidup/Meninggal.'],
['Nama Ibu', 'Opsional.'],
['NIK Ibu', 'Opsional.'],
['Tahun Lahir Ibu', 'Opsional, angka 4 digit.'],
['Pendidikan Ibu', 'Opsional.'],
['Pekerjaan Ibu', 'Opsional.'],
['No. HP Ibu', 'Opsional.'],
['Alamat Ibu', 'Opsional.'],
['Status Hidup Ibu', 'Opsional, isi Ya/Tidak atau Hidup/Meninggal.'],
['Nama Wali', 'Opsional.'],
['NIK Wali', 'Opsional.'],
['Tahun Lahir Wali', 'Opsional, angka 4 digit.'],
['Pendidikan Wali', 'Opsional.'],
['Pekerjaan Wali', 'Opsional.'],
['No. HP Wali', 'Opsional.'],
['Alamat Wali', 'Opsional.'],
['Status Hidup Wali', 'Opsional, isi Ya/Tidak atau Hidup/Meninggal.'],
], null, 'A1');
foreach (range('A', 'B') as $column) {
$guideSheet->getColumnDimension($column)->setAutoSize(true);
}
$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');
abort_unless(file_exists($path), 404, 'File contoh import siswa belum tersedia.');
return response()->file($path, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => 'attachment; filename="contoh-import-siswa.xlsx"',
]);
}
protected function validateStudent(Request $request, ?Student $student = null): array
{
$data = $request->validate([
'student_number' => ['nullable', 'string', 'max:30', Rule::unique('students', 'student_number')->ignore($student?->id)],
'nisn' => ['nullable', 'string', 'max:30', Rule::unique('students', 'nisn')->ignore($student?->id)],
'nik' => ['nullable', 'string', 'max:30', Rule::unique('students', 'nik')->ignore($student?->id)],
'full_name' => ['required', 'string', 'max:150'],
'gender' => ['required', Rule::in(['L', 'P'])],
'place_of_birth' => ['nullable', 'string', 'max:100'],
'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()
->when(
$student?->class_id,
fn ($query) => $query->where('is_active', true)->orWhere('id', $student->class_id),
fn ($query) => $query->where('is_active', true)
)
->orderBy('name');
return [
'academicYears' => AcademicYear::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
'departments' => Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
'classes' => SchoolClass::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
'provinces' => Province::orderBy('name')->pluck('name', 'id'),
'classOptions' => $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'])->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.',
],
};
}
}