diff --git a/app/Http/Controllers/Api/Integration/AcademicYearSyncController.php b/app/Http/Controllers/Api/Integration/AcademicYearSyncController.php new file mode 100644 index 0000000..3a70981 --- /dev/null +++ b/app/Http/Controllers/Api/Integration/AcademicYearSyncController.php @@ -0,0 +1,32 @@ +attributes->get('syncToken'); + + abort_unless(collect($token->abilities ?? [])->contains('academic_years'), 403, 'Token tidak memiliki akses ke data tahun ajaran.'); + + $items = AcademicYear::query() + ->orderByDesc('is_active') + ->orderByDesc('start_year') + ->get(['id', 'name', 'start_year', 'end_year', 'is_active', 'updated_at']); + + return response()->json([ + 'success' => true, + 'source' => 'database-kesiswaan', + 'resource' => 'academic_years', + 'count' => $items->count(), + 'data' => $items, + ]); + } +} diff --git a/app/Http/Controllers/Api/Integration/DepartmentSyncController.php b/app/Http/Controllers/Api/Integration/DepartmentSyncController.php new file mode 100644 index 0000000..ed3da2e --- /dev/null +++ b/app/Http/Controllers/Api/Integration/DepartmentSyncController.php @@ -0,0 +1,31 @@ +attributes->get('syncToken'); + + abort_unless(collect($token->abilities ?? [])->contains('departments'), 403, 'Token tidak memiliki akses ke data jurusan.'); + + $items = Department::query() + ->orderBy('name') + ->get(['id', 'code', 'name', 'description', 'is_active', 'updated_at']); + + return response()->json([ + 'success' => true, + 'source' => 'database-kesiswaan', + 'resource' => 'departments', + 'count' => $items->count(), + 'data' => $items, + ]); + } +} diff --git a/app/Http/Controllers/Api/Integration/SchoolClassSyncController.php b/app/Http/Controllers/Api/Integration/SchoolClassSyncController.php new file mode 100644 index 0000000..dcda414 --- /dev/null +++ b/app/Http/Controllers/Api/Integration/SchoolClassSyncController.php @@ -0,0 +1,42 @@ +attributes->get('syncToken'); + + abort_unless(collect($token->abilities ?? [])->contains('classes'), 403, 'Token tidak memiliki akses ke data kelas.'); + + $items = SchoolClass::query() + ->orderByDesc('academic_year_id') + ->orderBy('name') + ->get([ + 'id', + 'academic_year_id', + 'department_id', + 'name', + 'level', + 'homeroom_teacher', + 'capacity', + 'is_active', + 'updated_at', + ]); + + return response()->json([ + 'success' => true, + 'source' => 'database-kesiswaan', + 'resource' => 'classes', + 'count' => $items->count(), + 'data' => $items, + ]); + } +} diff --git a/app/Http/Controllers/Api/Integration/StudentSyncController.php b/app/Http/Controllers/Api/Integration/StudentSyncController.php new file mode 100644 index 0000000..b370867 --- /dev/null +++ b/app/Http/Controllers/Api/Integration/StudentSyncController.php @@ -0,0 +1,46 @@ +attributes->get('syncToken'); + + abort_unless(collect($token->abilities ?? [])->contains('students'), 403, 'Token tidak memiliki akses ke data siswa.'); + + $items = Student::query() + ->orderBy('full_name') + ->get([ + 'id', + 'student_number', + 'nisn', + 'full_name', + 'gender', + 'academic_year_id', + 'department_id', + 'class_id', + 'status', + 'phone_number', + 'email', + 'photo_path', + 'notes', + 'updated_at', + ]); + + return response()->json([ + 'success' => true, + 'source' => 'database-kesiswaan', + 'resource' => 'students', + 'count' => $items->count(), + 'data' => $items, + ]); + } +} diff --git a/app/Http/Controllers/GraduatedStudentController.php b/app/Http/Controllers/GraduatedStudentController.php new file mode 100644 index 0000000..c2eb1c4 --- /dev/null +++ b/app/Http/Controllers/GraduatedStudentController.php @@ -0,0 +1,168 @@ +get('search')); + $academicYearId = $request->get('academic_year_id'); + $departmentId = $request->get('department_id'); + $classId = $request->get('class_id'); + $sort = (string) $request->get('sort', 'full_name'); + $direction = strtolower((string) $request->get('direction', 'asc')) === 'desc' ? 'desc' : 'asc'; + + $allowedSorts = [ + 'full_name' => 'full_name', + 'student_number' => 'student_number', + 'academic_year' => 'academic_year_id', + 'department' => 'department_id', + 'class' => 'class_id', + 'graduation_date' => 'graduation_date', + 'created_at' => 'created_at', + ]; + + if (! array_key_exists($sort, $allowedSorts)) { + $sort = 'full_name'; + } + + $graduations = StudentGraduation::query() + ->with(['academicYear', 'department', 'classRoom']) + ->when($search !== '', function ($query) use ($search) { + $query->where(function ($subQuery) use ($search) { + $subQuery->where('full_name', 'like', "%{$search}%") + ->orWhere('student_number', 'like', "%{$search}%") + ->orWhere('nisn', 'like', "%{$search}%"); + }); + }) + ->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId)) + ->when($departmentId, fn ($query) => $query->where('department_id', $departmentId)) + ->when($classId, fn ($query) => $query->where('class_id', $classId)) + ->orderBy($allowedSorts[$sort], $direction) + ->orderBy('id', $direction) + ->paginate(10) + ->withQueryString(); + + $summary = [ + 'total' => $graduations->total(), + 'with_skl' => StudentGraduation::query()->whereNotNull('skl_file_path')->count(), + 'with_ijazah' => StudentGraduation::query()->whereNotNull('ijazah_file_path')->count(), + ]; + + return view('graduated-students.index', [ + 'graduations' => $graduations, + 'search' => $search, + 'filters' => [ + 'academic_year_id' => $academicYearId, + 'department_id' => $departmentId, + 'class_id' => $classId, + ], + 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), + 'departments' => Department::orderBy('name')->pluck('name', 'id'), + 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), + 'sort' => $sort, + 'direction' => $direction, + 'summary' => $summary, + ]); + } + + public function show(StudentGraduation $graduatedStudent): View + { + $graduatedStudent->load(['academicYear', 'department', 'classRoom']); + + return view('graduated-students.show', [ + 'graduation' => $graduatedStudent, + ]); + } + + public function update(Request $request, StudentGraduation $graduatedStudent): RedirectResponse + { + $validated = $request->validate([ + 'graduation_date' => ['nullable', 'date'], + 'graduation_note' => ['nullable', 'string'], + 'skl_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], + 'ijazah_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:4096'], + ], [ + 'skl_file.mimes' => 'File SKL harus berupa PDF, JPG, JPEG, atau PNG.', + 'skl_file.max' => 'Ukuran file SKL maksimal 4 MB.', + 'ijazah_file.mimes' => 'File ijazah harus berupa PDF, JPG, JPEG, atau PNG.', + 'ijazah_file.max' => 'Ukuran file ijazah maksimal 4 MB.', + ]); + + $graduatedStudent->graduation_date = $validated['graduation_date'] ?? $graduatedStudent->graduation_date; + $graduatedStudent->graduation_note = $validated['graduation_note'] ?? $graduatedStudent->graduation_note; + + if ($request->hasFile('skl_file')) { + if ($graduatedStudent->skl_file_path && Storage::disk('public')->exists($graduatedStudent->skl_file_path)) { + Storage::disk('public')->delete($graduatedStudent->skl_file_path); + } + + $file = $request->file('skl_file'); + $path = $file->store("student-graduation-documents/{$graduatedStudent->student_id}/skl", 'public'); + + $graduatedStudent->skl_file_name = $file->getClientOriginalName(); + $graduatedStudent->skl_file_path = $path; + $graduatedStudent->skl_mime_type = $file->getMimeType(); + $graduatedStudent->skl_file_size = $file->getSize(); + } + + if ($request->hasFile('ijazah_file')) { + if ($graduatedStudent->ijazah_file_path && Storage::disk('public')->exists($graduatedStudent->ijazah_file_path)) { + Storage::disk('public')->delete($graduatedStudent->ijazah_file_path); + } + + $file = $request->file('ijazah_file'); + $path = $file->store("student-graduation-documents/{$graduatedStudent->student_id}/ijazah", 'public'); + + $graduatedStudent->ijazah_file_name = $file->getClientOriginalName(); + $graduatedStudent->ijazah_file_path = $path; + $graduatedStudent->ijazah_mime_type = $file->getMimeType(); + $graduatedStudent->ijazah_file_size = $file->getSize(); + } + + $graduatedStudent->save(); + + Alert::success('Berhasil', 'Arsip siswa lulus berhasil diperbarui.'); + + return redirect()->route('graduated-students.show', $graduatedStudent)->with('success', 'Arsip siswa lulus berhasil diperbarui.'); + } + + public function previewDocument(StudentGraduation $graduatedStudent, string $document): View + { + abort_unless(in_array($document, ['skl', 'ijazah'], true), 404); + + $filePathField = $document === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; + $fileNameField = $document === 'skl' ? 'skl_file_name' : 'ijazah_file_name'; + $mimeTypeField = $document === 'skl' ? 'skl_mime_type' : 'ijazah_mime_type'; + + abort_unless($graduatedStudent->{$filePathField} && Storage::disk('public')->exists($graduatedStudent->{$filePathField}), 404); + + $mimeType = $graduatedStudent->{$mimeTypeField} ?: Storage::disk('public')->mimeType($graduatedStudent->{$filePathField}); + $fileUrl = asset('storage/' . $graduatedStudent->{$filePathField}); + $isImage = Str::startsWith((string) $mimeType, 'image/'); + $isPdf = $mimeType === 'application/pdf'; + + return view('graduated-students.preview-document', [ + 'graduation' => $graduatedStudent, + 'documentType' => $document, + 'documentLabel' => $document === 'skl' ? 'SKL' : 'Ijazah', + 'fileName' => $graduatedStudent->{$fileNameField}, + 'fileUrl' => $fileUrl, + 'mimeType' => $mimeType, + 'isImage' => $isImage, + 'isPdf' => $isPdf, + ]); + } +} diff --git a/app/Http/Controllers/GraduationBulkUploadController.php b/app/Http/Controllers/GraduationBulkUploadController.php new file mode 100644 index 0000000..99bf578 --- /dev/null +++ b/app/Http/Controllers/GraduationBulkUploadController.php @@ -0,0 +1,183 @@ + $request->session()->get('graduation_bulk_upload_preview'), + 'documentTypes' => [ + 'skl' => 'SKL', + 'ijazah' => 'Ijazah', + ], + 'matchByOptions' => [ + 'student_number' => 'NIS', + 'nisn' => 'NISN', + ], + ]); + } + + public function preview(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'document_type' => ['required', 'in:skl,ijazah'], + 'match_by' => ['required', 'in:student_number,nisn'], + 'documents' => ['required', 'array', 'min:1', 'max:10'], + 'documents.*' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:2048'], + ], [ + 'documents.required' => 'Pilih minimal satu file dokumen untuk dipreview.', + 'documents.max' => 'Maksimal 10 file per batch upload.', + 'documents.*.mimes' => 'Dokumen hanya boleh berupa PDF, JPG, JPEG, atau PNG.', + 'documents.*.max' => 'Ukuran tiap dokumen maksimal 2 MB.', + ]); + + $graduations = StudentGraduation::query()->get()->keyBy($validated['match_by']); + $rows = []; + $summary = [ + 'total_files' => 0, + 'matched' => 0, + 'unmatched' => 0, + 'will_replace' => 0, + 'ready_to_upload' => 0, + ]; + + foreach ($request->file('documents', []) as $file) { + $summary['total_files']++; + + $originalName = $file->getClientOriginalName(); + $baseName = pathinfo($originalName, PATHINFO_FILENAME); + $normalizedKey = trim($baseName); + $graduation = $graduations->get($normalizedKey); + $existingPathField = $validated['document_type'] === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; + + $storedTempPath = $file->store('temp/graduation-bulk-upload', 'public'); + + $row = [ + 'original_name' => $originalName, + 'stored_temp_path' => $storedTempPath, + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'match_key' => $normalizedKey, + 'graduation_id' => $graduation?->id, + 'student_name' => $graduation?->full_name, + 'identifier_value' => $graduation?->{$validated['match_by']}, + 'status' => $graduation ? 'matched' : 'unmatched', + 'will_replace' => $graduation ? filled($graduation->{$existingPathField}) : false, + ]; + + if ($graduation) { + $summary['matched']++; + $summary['ready_to_upload']++; + + if ($row['will_replace']) { + $summary['will_replace']++; + } + } else { + $summary['unmatched']++; + } + + $rows[] = $row; + } + + $preview = [ + 'token' => (string) Str::uuid(), + 'document_type' => $validated['document_type'], + 'document_label' => $validated['document_type'] === 'skl' ? 'SKL' : 'Ijazah', + 'match_by' => $validated['match_by'], + 'match_by_label' => $validated['match_by'] === 'student_number' ? 'NIS' : 'NISN', + 'rows' => $rows, + 'summary' => $summary, + ]; + + $request->session()->put('graduation_bulk_upload_preview', $preview); + + return redirect()->route('graduated-students.bulk-upload.index'); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'preview_token' => ['required', 'string'], + ]); + + $preview = $request->session()->get('graduation_bulk_upload_preview'); + + if (! $preview || ($preview['token'] ?? null) !== $request->string('preview_token')->value()) { + return back()->withErrors([ + 'preview_token' => 'Preview bulk upload sudah tidak valid. Silakan upload ulang dokumen.', + ]); + } + + $documentType = $preview['document_type']; + $fileNameField = $documentType === 'skl' ? 'skl_file_name' : 'ijazah_file_name'; + $filePathField = $documentType === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; + $mimeTypeField = $documentType === 'skl' ? 'skl_mime_type' : 'ijazah_mime_type'; + $fileSizeField = $documentType === 'skl' ? 'skl_file_size' : 'ijazah_file_size'; + + $processed = 0; + $skipped = 0; + + DB::transaction(function () use ($preview, $documentType, $fileNameField, $filePathField, $mimeTypeField, $fileSizeField, &$processed, &$skipped) { + foreach ($preview['rows'] as $row) { + if (($row['status'] ?? null) !== 'matched' || empty($row['graduation_id'])) { + $skipped++; + continue; + } + + $graduation = StudentGraduation::query()->find($row['graduation_id']); + + if (! $graduation) { + $skipped++; + continue; + } + + if (! Storage::disk('public')->exists($row['stored_temp_path'])) { + $skipped++; + continue; + } + + if ($graduation->{$filePathField} && Storage::disk('public')->exists($graduation->{$filePathField})) { + Storage::disk('public')->delete($graduation->{$filePathField}); + } + + $extension = pathinfo($row['original_name'], PATHINFO_EXTENSION); + $finalPath = "student-graduation-documents/{$graduation->student_id}/{$documentType}/" . Str::uuid() . ($extension ? '.' . $extension : ''); + Storage::disk('public')->move($row['stored_temp_path'], $finalPath); + + $graduation->update([ + $fileNameField => $row['original_name'], + $filePathField => $finalPath, + $mimeTypeField => $row['mime_type'], + $fileSizeField => $row['file_size'], + ]); + + $processed++; + } + }); + + foreach ($preview['rows'] as $row) { + if (! empty($row['stored_temp_path']) && Storage::disk('public')->exists($row['stored_temp_path'])) { + Storage::disk('public')->delete($row['stored_temp_path']); + } + } + + $request->session()->forget('graduation_bulk_upload_preview'); + + Alert::success('Berhasil', "Bulk upload {$preview['document_label']} selesai. {$processed} dokumen diproses, {$skipped} dilewati."); + + return redirect() + ->route('graduated-students.bulk-upload.index') + ->with('success', "Bulk upload {$preview['document_label']} selesai. {$processed} dokumen diproses, {$skipped} dilewati."); + } +} diff --git a/app/Http/Controllers/PublicStudentLookupController.php b/app/Http/Controllers/PublicStudentLookupController.php index cf08f09..9aa1222 100644 --- a/app/Http/Controllers/PublicStudentLookupController.php +++ b/app/Http/Controllers/PublicStudentLookupController.php @@ -5,9 +5,11 @@ namespace App\Http\Controllers; use App\Models\DocumentType; use App\Models\Student; use App\Models\StudentDocument; +use App\Models\StudentGraduation; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\View\View; +use Symfony\Component\HttpFoundation\StreamedResponse; class PublicStudentLookupController extends Controller { @@ -44,6 +46,9 @@ class PublicStudentLookupController extends Controller if ($selectedStudent) { $requiredDocumentSummary = $this->buildRequiredDocumentSummary($selectedStudent); $matchedBy = $this->detectMatchedBy($selectedStudent, $query); + $selectedStudent->public_graduation_record = $selectedStudent->status === 'graduated' + ? StudentGraduation::query()->where('student_id', $selectedStudent->id)->first() + : null; } $students->each(function (Student $student) { @@ -136,6 +141,21 @@ class PublicStudentLookupController extends Controller && (str_contains($name, 'foto') || str_contains($fileName, 'foto')); } + public function downloadGraduationDocument(Student $student, string $document): StreamedResponse + { + abort_unless($student->status === 'graduated', 404); + abort_unless(in_array($document, ['skl', 'ijazah'], true), 404); + + $graduation = StudentGraduation::query()->where('student_id', $student->id)->firstOrFail(); + + $filePathField = $document === 'skl' ? 'skl_file_path' : 'ijazah_file_path'; + $fileNameField = $document === 'skl' ? 'skl_file_name' : 'ijazah_file_name'; + + abort_unless($graduation->{$filePathField} && Storage::disk('public')->exists($graduation->{$filePathField}), 404); + + return Storage::disk('public')->download($graduation->{$filePathField}, $graduation->{$fileNameField} ?: basename($graduation->{$filePathField})); + } + protected function buildRequiredDocumentSummary(Student $student): array { $requiredTypes = DocumentType::query() diff --git a/app/Http/Controllers/StudentController.php b/app/Http/Controllers/StudentController.php index 1f7d9d8..64cf6f0 100644 --- a/app/Http/Controllers/StudentController.php +++ b/app/Http/Controllers/StudentController.php @@ -13,6 +13,7 @@ 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; @@ -80,7 +81,7 @@ class StudentController extends Controller ->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' && 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)) @@ -150,6 +151,29 @@ class StudentController extends Controller 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.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, @@ -162,16 +186,19 @@ class StudentController extends Controller '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', - ], + '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, ]); } @@ -188,6 +215,7 @@ class StudentController extends Controller $this->syncAcademicHistory($student, $data); $this->syncExitRecord($student, $data); $this->syncTransferRecord($student, $data); + $this->syncGraduationRecord($student, $data); Alert::success('Berhasil', 'Data siswa berhasil ditambahkan.'); @@ -261,6 +289,7 @@ class StudentController extends Controller $this->syncAcademicHistory($student, $data); $this->syncExitRecord($student, $data); $this->syncTransferRecord($student, $data); + $this->syncGraduationRecord($student, $data); Alert::success('Berhasil', 'Data siswa berhasil diperbarui.'); @@ -750,6 +779,60 @@ class StudentController extends Controller 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(); + } + + $graduationRecord->save(); + + return; + } + + StudentGraduation::query()->where('student_id', $student->id)->delete(); + } + protected function importRules(): array { return [ @@ -1114,12 +1197,6 @@ class StudentController extends Controller 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', diff --git a/app/Http/Controllers/StudentPromotionController.php b/app/Http/Controllers/StudentPromotionController.php index e953223..6be4f18 100644 --- a/app/Http/Controllers/StudentPromotionController.php +++ b/app/Http/Controllers/StudentPromotionController.php @@ -6,6 +6,7 @@ use App\Models\AcademicYear; use App\Models\SchoolClass; use App\Models\Student; use App\Models\StudentAcademicHistory; +use App\Models\StudentGraduation; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -19,31 +20,86 @@ class StudentPromotionController extends Controller { $fromAcademicYearId = $request->integer('from_academic_year_id') ?: AcademicYear::where('is_active', true)->value('id'); $toAcademicYearId = $request->integer('to_academic_year_id'); + $selectedClassId = $request->integer('class_id'); $academicYears = AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get(); + $availableClasses = collect(); + $classSummaries = collect(); + $selectedClass = null; $students = collect(); if ($fromAcademicYearId) { - $students = Student::query() - ->with(['academicYear', 'department', 'classRoom']) + $availableClasses = SchoolClass::query() + ->with(['department']) ->where('academic_year_id', $fromAcademicYearId) - ->where('status', 'active') - ->orderBy('full_name') + ->where('is_active', true) + ->withCount([ + 'students as active_students_count' => fn ($query) => $query->where('status', 'active'), + ]) + ->orderBy('level') + ->orderBy('name') ->get() - ->map(function (Student $student) use ($toAcademicYearId) { - $student->promotion_recommendation = $this->buildPromotionRecommendation($student, $toAcademicYearId); + ->filter(fn (SchoolClass $classRoom) => $classRoom->active_students_count > 0) + ->values(); - return $student; - }); + $classSummaries = $availableClasses->map(function (SchoolClass $classRoom) use ($toAcademicYearId) { + $sampleStudent = Student::query() + ->with(['classRoom']) + ->where('academic_year_id', $classRoom->academic_year_id) + ->where('class_id', $classRoom->id) + ->where('status', 'active') + ->orderBy('full_name') + ->first(); + + $recommendation = $sampleStudent + ? $this->buildPromotionRecommendation($sampleStudent, $toAcademicYearId) + : [ + 'label' => 'Belum ada siswa aktif', + 'target_class_name' => null, + ]; + + return (object) [ + 'id' => $classRoom->id, + 'name' => $classRoom->name, + 'level' => $classRoom->level, + 'department_name' => $classRoom->department?->name, + 'active_students_count' => $classRoom->active_students_count, + 'recommendation_label' => $recommendation['label'] ?? '-', + 'recommendation_target_name' => $recommendation['target_class_name'] ?? null, + ]; + })->values(); + + if ($selectedClassId) { + $selectedClass = $availableClasses->firstWhere('id', $selectedClassId); + + if ($selectedClass) { + $students = Student::query() + ->with(['academicYear', 'department', 'classRoom']) + ->where('academic_year_id', $fromAcademicYearId) + ->where('class_id', $selectedClassId) + ->where('status', 'active') + ->orderBy('full_name') + ->get() + ->map(function (Student $student) use ($toAcademicYearId) { + $student->promotion_recommendation = $this->buildPromotionRecommendation($student, $toAcademicYearId); + + return $student; + }); + } + } } return view('student-promotions.index', [ 'academicYears' => $academicYears, + 'availableClasses' => $availableClasses, + 'classSummaries' => $classSummaries, + 'selectedClass' => $selectedClass, 'students' => $students, 'filters' => [ 'from_academic_year_id' => $fromAcademicYearId, 'to_academic_year_id' => $toAcademicYearId, + 'class_id' => $selectedClassId, ], ]); } @@ -53,6 +109,7 @@ class StudentPromotionController extends Controller $data = $request->validate([ 'from_academic_year_id' => ['required', 'exists:academic_years,id'], 'to_academic_year_id' => ['required', 'exists:academic_years,id', 'different:from_academic_year_id'], + 'class_id' => ['nullable', 'exists:classes,id'], 'promotions' => ['required', 'array', 'min:1'], 'promotions.*.student_id' => ['required', 'exists:students,id'], 'promotions.*.target_class_id' => ['nullable', 'exists:classes,id'], @@ -102,6 +159,12 @@ class StudentPromotionController extends Controller ->withInput(); } + if (! empty($data['class_id']) && (int) $student->class_id !== (int) $data['class_id']) { + return back() + ->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berasal dari kelas asal yang dipilih."]) + ->withInput(); + } + if ($student->status !== 'active') { return back() ->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berstatus aktif sehingga tidak bisa diproses promosi."]) @@ -200,6 +263,22 @@ class StudentPromotionController extends Controller 'notes' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.', ]); + StudentGraduation::updateOrCreate( + ['student_id' => $student->id], + [ + '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' => now()->toDateString(), + 'graduation_note' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.', + 'status' => 'graduated', + ] + ); + $processed++; continue; } @@ -245,6 +324,7 @@ class StudentPromotionController extends Controller ->route('student-promotions.index', [ 'from_academic_year_id' => $data['from_academic_year_id'], 'to_academic_year_id' => $data['to_academic_year_id'], + 'class_id' => $data['class_id'] ?? null, ]) ->with('success', $processed . ' siswa berhasil diproses melalui modul promosi.'); } diff --git a/app/Http/Controllers/SyncTokenController.php b/app/Http/Controllers/SyncTokenController.php new file mode 100644 index 0000000..7b00717 --- /dev/null +++ b/app/Http/Controllers/SyncTokenController.php @@ -0,0 +1,57 @@ + SyncToken::query()->with('creator')->latest()->get(), + 'plainToken' => $request->session()->get('sync_token_plain'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'abilities' => ['nullable', 'array'], + 'abilities.*' => ['string', 'max:100'], + ]); + + $plainToken = 'dksync_' . Str::random(48); + + SyncToken::query()->create([ + 'name' => $data['name'], + 'token_hash' => Hash::make($plainToken), + 'abilities' => $data['abilities'] ?? ['academic_years', 'departments', 'classes', 'students'], + 'created_by' => $request->user()->id, + ]); + + return redirect() + ->route('settings.sync-tokens.index') + ->with('status', 'Token sinkronisasi berhasil dibuat.') + ->with('sync_token_plain', $plainToken); + } + + public function revoke(SyncToken $syncToken): RedirectResponse + { + if (! $syncToken->revoked_at) { + $syncToken->update([ + 'revoked_at' => now(), + ]); + } + + return redirect() + ->route('settings.sync-tokens.index') + ->with('status', 'Token sinkronisasi berhasil dinonaktifkan.'); + } +} diff --git a/app/Http/Middleware/EnsureValidSyncToken.php b/app/Http/Middleware/EnsureValidSyncToken.php new file mode 100644 index 0000000..ba70bd6 --- /dev/null +++ b/app/Http/Middleware/EnsureValidSyncToken.php @@ -0,0 +1,44 @@ +bearerToken(); + + if (! $bearerToken) { + return response()->json([ + 'success' => false, + 'message' => 'Token sinkronisasi tidak ditemukan.', + ], 401); + } + + $token = SyncToken::query() + ->whereNull('revoked_at') + ->get() + ->first(fn (SyncToken $item) => Hash::check($bearerToken, $item->token_hash)); + + if (! $token) { + return response()->json([ + 'success' => false, + 'message' => 'Token sinkronisasi tidak valid atau sudah dinonaktifkan.', + ], 401); + } + + $token->forceFill([ + 'last_used_at' => now(), + ])->save(); + + $request->attributes->set('syncToken', $token); + + return $next($request); + } +} diff --git a/app/Models/Student.php b/app/Models/Student.php index ff60b78..2e253ef 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -145,6 +145,11 @@ class Student extends Model return $this->hasOne(StudentTransfer::class); } + public function graduationRecord(): HasOne + { + return $this->hasOne(StudentGraduation::class); + } + public function achievements(): HasMany { return $this->hasMany(StudentAchievement::class)->latest('achievement_year')->latest('achievement_date'); diff --git a/app/Models/StudentGraduation.php b/app/Models/StudentGraduation.php new file mode 100644 index 0000000..d69045c --- /dev/null +++ b/app/Models/StudentGraduation.php @@ -0,0 +1,62 @@ + 'date', + 'graduation_date' => 'date', + ]; + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function academicYear(): BelongsTo + { + return $this->belongsTo(AcademicYear::class); + } + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + public function classRoom(): BelongsTo + { + return $this->belongsTo(SchoolClass::class, 'class_id'); + } +} diff --git a/app/Models/SyncToken.php b/app/Models/SyncToken.php new file mode 100644 index 0000000..0e2ac13 --- /dev/null +++ b/app/Models/SyncToken.php @@ -0,0 +1,40 @@ + 'array', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function getIsRevokedAttribute(): bool + { + return ! is_null($this->revoked_at); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 6d297f5..cfc0d4b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,12 +7,14 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'role' => \App\Http\Middleware\EnsureUserHasRole::class, + 'sync.token' => \App\Http\Middleware\EnsureValidSyncToken::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_04_13_141742_create_sync_tokens_table.php b/database/migrations/2026_04_13_141742_create_sync_tokens_table.php new file mode 100644 index 0000000..47df92f --- /dev/null +++ b/database/migrations/2026_04_13_141742_create_sync_tokens_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('token_hash')->unique(); + $table->text('abilities')->nullable(); + $table->foreignId('created_by')->constrained('users')->cascadeOnDelete(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sync_tokens'); + } +}; diff --git a/database/migrations/2026_04_14_060000_create_student_graduations_table.php b/database/migrations/2026_04_14_060000_create_student_graduations_table.php new file mode 100644 index 0000000..0ab6d5e --- /dev/null +++ b/database/migrations/2026_04_14_060000_create_student_graduations_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->string('student_number')->nullable(); + $table->string('nisn')->nullable(); + $table->string('full_name'); + $table->foreignId('academic_year_id')->nullable()->constrained('academic_years')->nullOnDelete(); + $table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete(); + $table->foreignId('class_id')->nullable()->constrained('classes')->nullOnDelete(); + $table->date('entry_date')->nullable(); + $table->date('graduation_date')->nullable(); + $table->text('graduation_note')->nullable(); + $table->string('skl_file_name')->nullable(); + $table->string('skl_file_path')->nullable(); + $table->string('skl_mime_type')->nullable(); + $table->unsignedBigInteger('skl_file_size')->nullable(); + $table->string('ijazah_file_name')->nullable(); + $table->string('ijazah_file_path')->nullable(); + $table->string('ijazah_mime_type')->nullable(); + $table->unsignedBigInteger('ijazah_file_size')->nullable(); + $table->string('status')->default('graduated'); + $table->timestamps(); + + $table->unique('student_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('student_graduations'); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 5d5569e..92b0f75 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -369,14 +369,179 @@ document.addEventListener('DOMContentLoaded', () => { if (promotionForm) { const rows = promotionForm.querySelectorAll('tbody tr'); + const counterElements = { + promote: document.querySelector('[data-promotion-counter="promote"]'), + repeat: document.querySelector('[data-promotion-counter="repeat"]'), + graduate: document.querySelector('[data-promotion-counter="graduate"]'), + skip: document.querySelector('[data-promotion-counter="skip"]'), + }; + const liveSummary = document.querySelector('[data-promotion-live-summary]'); + const capacitySummary = document.querySelector('[data-promotion-capacity-summary]'); + const finalPreview = document.querySelector('[data-promotion-final-preview]'); + const submitButton = document.querySelector('[data-promotion-submit-button]'); + const toolbar = document.querySelector('[data-promotion-toolbar]'); + + const getOptionsForAction = (targetSelect, actionValue) => { + const optionKey = actionValue === 'repeat' ? 'optionsRepeat' : 'optionsPromote'; + return JSON.parse(targetSelect?.dataset?.[optionKey] || '[]'); + }; + + const calculateCounts = () => { + const counts = { promote: 0, repeat: 0, graduate: 0, skip: 0 }; + + rows.forEach((row) => { + const actionSelect = row.querySelector('.promotion-action-select'); + const actionValue = actionSelect?.value || 'skip'; + if (counts[actionValue] !== undefined) { + counts[actionValue] += 1; + } + }); + + return counts; + }; + + const updateCounters = () => { + const counts = calculateCounts(); + + Object.entries(counterElements).forEach(([key, element]) => { + if (element) { + element.textContent = counts[key]; + } + }); + + if (liveSummary) { + liveSummary.innerHTML = `Ringkasan live: ${counts.promote} naik, ${counts.repeat} tinggal kelas, ${counts.graduate} lulus, dan ${counts.skip} dilewati.`; + } + }; + + const updateFinalPreview = () => { + if (!finalPreview) { + return; + } + + const counts = calculateCounts(); + const totalProcessed = counts.promote + counts.repeat + counts.graduate; + + finalPreview.classList.remove('hidden'); + finalPreview.innerHTML = ` +
Aturan nama file yang disarankan
+2024001.pdf9988776655.pdf| Nama File | +Key Terdeteksi | +Siswa Lulus | +Status | +
|---|---|---|---|
| {{ $row['original_name'] }} | +{{ $row['match_key'] ?: '-' }} | +
+ @if ($row['graduation_id'])
+ {{ $row['student_name'] }}
+ {{ $preview['match_by_label'] }}: {{ $row['identifier_value'] ?: '-' }}
+ @else
+ Tidak ditemukan
+ @endif
+ |
+
+ @if ($row['status'] === 'matched')
+
+ Siap Diproses
+ @if ($row['will_replace'])
+ Replace Dokumen Lama
+ @endif
+
+ @else
+ Tidak Cocok
+ @endif
+ |
+
+ Modul arsip khusus lulusan untuk menelusuri identitas akhir siswa, akademik terakhir, tanggal kelulusan, serta kelengkapan dokumen SKL dan ijazah. +
+| + Lulusan {{ $sortIcon('full_name') }} + | ++ Akademik Terakhir {{ $sortIcon('academic_year') }} + | ++ Kelulusan {{ $sortIcon('graduation_date') }} + | +Dokumen | +Aksi | +
|---|---|---|---|---|
|
+
+
+
+ {{ strtoupper(mb_substr($graduation->full_name, 0, 1)) }}
+
+
+
+ {{ $graduation->full_name }}
+ NIS: {{ $graduation->student_number ?: '-' }} · NISN: {{ $graduation->nisn ?: '-' }}
+ |
+
+ {{ $graduation->academicYear?->name ?: '-' }}
+ {{ $graduation->department?->name ?: '-' }}
+ Kelas terakhir: {{ $graduation->classRoom?->name ?: '-' }}
+ |
+
+ Lulus
+ Tanggal lulus: {{ optional($graduation->graduation_date)->format('d-m-Y') ?: '-' }}
+ |
+ + + | ++ Detail Arsip + | +
| Belum ada arsip siswa lulus. | +||||
Format file ini belum didukung untuk preview langsung di browser. Silakan gunakan tombol buka file asli.
+