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

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