1125 lines
44 KiB
PHP
1125 lines
44 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'])
|
|
->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;
|
|
|
|
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']);
|
|
|
|
$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.',
|
|
],
|
|
};
|
|
}
|
|
}
|