From 692b84cc1b2bddd9a4ede329d65f35aa7118770c Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 1 Sep 2026 21:39:46 +0700 Subject: [PATCH] feat: add open semesters badge to academic terms and remove course registration pages - Added a new column to display open semesters in the academic terms table with badges. - Removed course registration related pages and components including index, create, and show. - Updated course registration types to reflect changes in the data structure. - Refactored admin routes for course registrations to streamline submission handling. --- .../Manage/CourseRegistrationController.php | 199 +++-- .../Manage/CourseRegistrationRequest.php | 75 -- .../Manage/SaveCourseRegistrationRequest.php | 25 + .../Manage/SignCourseRegistrationRequest.php | 20 + app/Models/CourseRegistrationSubmission.php | 29 +- app/Models/User.php | 50 ++ .../Manage/CourseRegistrationService.php | 395 +++++---- app/Support/PermissionCatalog.php | 2 +- ..._course_registration_submissions_table.php | 3 +- database/seeders/DatabaseSeeder.php | 6 +- database/seeders/RolePermissionSeeder.php | 4 + resources/js/components/app-sidebar.tsx | 19 +- resources/js/components/signature-pad.tsx | 77 +- resources/js/components/ui/tabs.tsx | 2 - .../manage/course-registrations/columns.tsx | 118 +-- .../manage/course-registrations/index.tsx | 396 +-------- .../manage/course-registrations/show.tsx | 783 +++++++++++++++--- .../admin/master/academic-terms/columns.tsx | 28 + .../student/course-registrations/columns.tsx | 90 -- .../student/course-registrations/create.tsx | 259 ------ .../student/course-registrations/index.tsx | 129 --- .../student/course-registrations/show.tsx | 180 ---- resources/js/types/course-registration.ts | 81 +- routes/admin.php | 26 +- 24 files changed, 1280 insertions(+), 1716 deletions(-) delete mode 100644 app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php create mode 100644 app/Http/Requests/Admin/Manage/SaveCourseRegistrationRequest.php create mode 100644 app/Http/Requests/Admin/Manage/SignCourseRegistrationRequest.php delete mode 100644 resources/js/pages/student/course-registrations/columns.tsx delete mode 100644 resources/js/pages/student/course-registrations/create.tsx delete mode 100644 resources/js/pages/student/course-registrations/index.tsx delete mode 100644 resources/js/pages/student/course-registrations/show.tsx diff --git a/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php b/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php index 6d91687..801d86b 100644 --- a/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php +++ b/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php @@ -3,17 +3,16 @@ namespace App\Http\Controllers\Admin\Manage; use App\Http\Controllers\Controller; -use App\Http\Requests\Admin\Manage\CourseRegistrationRequest; use App\Http\Requests\Admin\Manage\RejectCourseRegistrationRequest; +use App\Http\Requests\Admin\Manage\SaveCourseRegistrationRequest; +use App\Http\Requests\Admin\Manage\SignCourseRegistrationRequest; use App\Http\Requests\PaginatedRequest; +use App\Models\CourseClass; use App\Models\CourseRegistrationSubmission; use App\Models\Student; -use App\Services\Admin\Manage\CourseClassService; use App\Services\Admin\Manage\CourseRegistrationService; use App\Services\Admin\Master\AcademicTermService; -use App\Services\Admin\Users\StudentService; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -21,109 +20,146 @@ class CourseRegistrationController extends Controller { public function __construct( private readonly CourseRegistrationService $service, - private readonly StudentService $studentService, private readonly AcademicTermService $academicTermService, - private readonly CourseClassService $courseClassService, ) {} - public function index(PaginatedRequest $request): Response + public function index(PaginatedRequest $request): Response|RedirectResponse { + $user = auth()->user(); + + if ($user->hasRole('mahasiswa')) { + return to_route('admin.manage.course-registrations.show', $user->student->id); + } + + $isKaprodi = $user->hasRole('kaprodi'); + return Inertia::render('admin/manage/course-registrations/index', [ + 'departments' => $this->service->departmentSummary(), 'registrations' => $this->service->paginated( ...$request->validatedWithDefaults(), - status: $request->validated('status'), - academicTermId: $request->validated('academic_term_id'), + departmentId: $request->validated('department_id'), + advisorLecturerId: ! $isKaprodi && $user->hasRole('dosen') ? $user->lecturer?->id : null, + ledDepartmentIds: $isKaprodi ? $user->ledDepartmentIds() : null, ), - 'students' => $this->studentService->getAllForSelect(), - 'academicTerms' => $this->academicTermService->getAllForSelect(), - 'courseClasses' => $this->courseClassService->getAllForSelect(), - 'filters' => $request->only(['status', 'academic_term_id']), + 'filters' => $request->only(['department_id']), ]); } - public function mine(Request $request): Response + public function show(Student $student): Response { - $student = $this->currentStudent($request); + $user = auth()->user(); + $isStudent = $user->hasRole('mahasiswa'); - $term = $this->academicTermService->getActive(); - $payment = $term ? $this->service->paymentStatus($student, $term) : null; - $activeSubmission = $term ? $this->service->currentSubmission($student, $term) : null; + abort_if($isStudent && $user->student->id !== $student->id, 403); + abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403); - return Inertia::render('student/course-registrations/index', [ - 'academicTerm' => $term, - 'payment' => $payment, - 'canSubmit' => $this->service->canSubmit($activeSubmission), - 'activeSubmission' => $activeSubmission, - 'submissions' => $this->service->submissionsFor($student), + $backHref = $isStudent + ? null + : route('admin.manage.course-registrations.index'); + + $canReview = ! $isStudent + && $user->can('approve-course-registrations') + && $user->canReviewCourseRegistrationOf($student); + + $canSignAsKaprodi = $user->isKaprodiOf($student); + $canSignAsAdvisor = $user->isAdvisorOf($student); + + $activeTerm = $this->academicTermService->getActive(); + $allCourseClasses = $activeTerm + ? $this->service->availableCourseClasses($student->department_id, $activeTerm->id) + : collect(); + + $semesters = $allCourseClasses + ->map(fn (CourseClass $courseClass) => $courseClass->course?->semester_number) + ->unique() + ->sort() + ->values(); + + $submissions = $semesters->mapWithKeys(fn (?int $semesterNumber) => [ + CourseRegistrationSubmission::semesterKey($semesterNumber) => $this->service->buildSubmissionPayload( + $student, + $activeTerm, + $semesterNumber, + $allCourseClasses, + ), ]); - } - - public function create(Request $request): Response|RedirectResponse - { - $student = $this->currentStudent($request); - - $student->load(['user.profile', 'department', 'academicAdvisor.user.profile']); - - $term = $this->academicTermService->getActive(); - $payment = $term ? $this->service->paymentStatus($student, $term) : null; - $submission = $term ? $this->service->currentSubmission($student, $term) : null; - - if (! $term || ! $payment['is_paid'] || ! $this->service->canSubmit($submission)) { - return to_route('student.course-registrations.index'); - } - - return Inertia::render('student/course-registrations/create', [ - 'student' => $student, - 'academicTerm' => $term, - 'submission' => $submission, - 'availableCourses' => $this->service->availableCourseClasses($student, $term), - ]); - } - - public function show(Request $request, CourseRegistrationSubmission $submission): Response - { - if ($request->routeIs('student.*')) { - $student = $this->currentStudent($request); - - abort_if($submission->student_id !== $student->id, 403); - - return Inertia::render('student/course-registrations/show', [ - 'submission' => $this->service->withDetails($submission), - ]); - } return Inertia::render('admin/manage/course-registrations/show', [ - 'submission' => $this->service->withDetails($submission), + 'submissions' => $submissions, + 'semesters' => $semesters->values(), + 'openSemesters' => $activeTerm?->open_semesters ?? [], + 'backHref' => $backHref, + 'canReview' => $canReview, + 'canSignAsKaprodi' => $canSignAsKaprodi, + 'canSignAsAdvisor' => $canSignAsAdvisor, ]); } - public function store(CourseRegistrationRequest $request): RedirectResponse + public function save(Student $student, string $semester, SaveCourseRegistrationRequest $request): RedirectResponse { - if ($request->routeIs('student.*')) { - $student = $this->currentStudent($request); - $term = $this->academicTermService->getActive(); + $semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester); - $submission = $this->service->sign( - $student, - $term, - $request->validated('course_class_ids'), - $request->file('signature'), - ); + $activeTerm = $this->academicTermService->getActive(); - Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditandatangani.']); + abort_if($activeTerm === null, 422); - return to_route('student.course-registrations.show', $submission); - } + $selectableIds = $this->service->selectableCourseClassIds( + $student->department_id, + $activeTerm->id, + $semesterNumber, + $activeTerm->open_semesters ?? [], + ); - $this->service->create($request->validated()); + $selectedIds = array_values(array_intersect( + $request->validated('course_class_ids') ?? [], + $selectableIds, + )); - Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']); + $submission = $this->service->findOrCreateForStudent($student, $activeTerm, $semesterNumber); - return to_route('admin.manage.course-registrations.index'); + $this->service->saveRegistrations( + $submission, + $selectedIds, + $activeTerm->id, + $student->id, + $request->file('signature'), + ); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disimpan.']); + + return to_route('admin.manage.course-registrations.show', $student->id); + } + + public function signAsKaprodi(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse + { + abort_unless(auth()->user()->isKaprodiOf($student), 403); + + $submission = $this->service->findOrCreateForSemester($student, $semester); + + $this->service->signAsKaprodi($submission, $request->file('signature')); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']); + + return to_route('admin.manage.course-registrations.show', $student->id); + } + + public function signAsAdvisor(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse + { + abort_unless(auth()->user()->isAdvisorOf($student), 403); + + $submission = $this->service->findOrCreateForSemester($student, $semester); + + $this->service->signAsAdvisor($submission, $request->file('signature')); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']); + + return to_route('admin.manage.course-registrations.show', $student->id); } public function approve(CourseRegistrationSubmission $submission): RedirectResponse { + abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403); + $this->service->approve($submission); return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disetujui.'])->back(); @@ -131,17 +167,10 @@ public function approve(CourseRegistrationSubmission $submission): RedirectRespo public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse { + abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403); + $this->service->reject($submission, $request->validated('reason')); return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back(); } - - private function currentStudent(Request $request): Student - { - $student = $request->user()->student; - - abort_if(! $student, 403); - - return $student; - } } diff --git a/app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php b/app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php deleted file mode 100644 index 58cc960..0000000 --- a/app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php +++ /dev/null @@ -1,75 +0,0 @@ -routeIs('student.*')) { - return $this->user()->can('create-course-registrations'); - } - - $student = $this->user()->student; - - if (! $student) { - return false; - } - - $term = app(AcademicTermService::class)->getActive(); - - if (! $term) { - return false; - } - - $service = app(CourseRegistrationService::class); - $submission = $service->currentSubmission($student, $term); - - if (! $service->canSubmit($submission)) { - return false; - } - - return $service->paymentStatus($student, $term)['is_paid']; - } - - public function rules(): array - { - if ($this->routeIs('student.*')) { - $student = $this->user()->student; - $term = app(AcademicTermService::class)->getActive(); - $allowedIds = $student && $term - ? app(CourseRegistrationService::class)->allowedCourseClassIds($student, $term) - : []; - - return [ - 'course_class_ids' => ['required', 'array', 'min:1'], - 'course_class_ids.*' => ['integer', Rule::in($allowedIds)], - 'signature' => ['required', 'image', 'max:2048'], - ]; - } - - return [ - 'student_id' => ['required', 'integer', Rule::exists('students', 'id')], - 'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], - 'course_class_ids' => ['required', 'array', 'min:1'], - 'course_class_ids.*' => [ - 'integer', - Rule::exists('course_classes', 'id'), - Rule::unique('course_registrations', 'course_class_id') - ->where('student_id', $this->input('student_id')), - ], - ]; - } - - public function attributes(): array - { - return [ - 'course_class_ids' => 'mata kuliah', - ]; - } -} diff --git a/app/Http/Requests/Admin/Manage/SaveCourseRegistrationRequest.php b/app/Http/Requests/Admin/Manage/SaveCourseRegistrationRequest.php new file mode 100644 index 0000000..54580e7 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/SaveCourseRegistrationRequest.php @@ -0,0 +1,25 @@ +route('student'); + + return $this->user()->hasRole('mahasiswa') + && $this->user()->student?->id === $student?->id; + } + + public function rules(): array + { + return [ + 'course_class_ids' => ['array'], + 'course_class_ids.*' => ['integer'], + 'signature' => ['nullable', 'file', 'image', 'max:2048'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/SignCourseRegistrationRequest.php b/app/Http/Requests/Admin/Manage/SignCourseRegistrationRequest.php new file mode 100644 index 0000000..ba18cac --- /dev/null +++ b/app/Http/Requests/Admin/Manage/SignCourseRegistrationRequest.php @@ -0,0 +1,20 @@ + ['required', 'file', 'image', 'max:2048'], + ]; + } +} diff --git a/app/Models/CourseRegistrationSubmission.php b/app/Models/CourseRegistrationSubmission.php index a97c9b9..faf9471 100644 --- a/app/Models/CourseRegistrationSubmission.php +++ b/app/Models/CourseRegistrationSubmission.php @@ -13,7 +13,7 @@ use Spatie\MediaLibrary\InteractsWithMedia; #[Guarded(['id'])] -#[Appends(['signature_url'])] +#[Appends(['signature_url', 'kaprodi_signature_url', 'advisor_signature_url'])] class CourseRegistrationSubmission extends Model implements HasMedia { use InteractsWithMedia; @@ -22,6 +22,7 @@ protected function casts(): array { return [ 'status' => RegistrationStatus::class, + 'semester_number' => 'integer', 'signed_at' => 'datetime', 'reviewed_at' => 'datetime', ]; @@ -30,6 +31,8 @@ protected function casts(): array public function registerMediaCollections(): void { $this->addMediaCollection('signature')->singleFile(); + $this->addMediaCollection('kaprodi_signature')->singleFile(); + $this->addMediaCollection('advisor_signature')->singleFile(); } public function student(): BelongsTo @@ -63,4 +66,28 @@ protected function signatureUrl(): Attribute get: fn () => $this->getFirstMediaUrl('signature') ?: null, ); } + + protected function kaprodiSignatureUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMediaUrl('kaprodi_signature') ?: null, + ); + } + + protected function advisorSignatureUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMediaUrl('advisor_signature') ?: null, + ); + } + + public static function semesterKey(?int $semesterNumber): string + { + return $semesterNumber === null ? 'lainnya' : (string) $semesterNumber; + } + + public static function parseSemesterKey(string $semester): ?int + { + return $semester === 'lainnya' ? null : (int) $semester; + } } diff --git a/app/Models/User.php b/app/Models/User.php index e6a8060..68107b4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -62,4 +62,54 @@ public function feedbacks(): HasMany { return $this->hasMany(Feedback::class); } + + public function isAdvisorOf(Student $student): bool + { + return $this->hasRole('dosen') && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id; + } + + public function isKaprodiOf(Student $student): bool + { + return $this->hasRole('kaprodi') && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id; + } + + /** + * Gates page access: the student's academic advisor, the kaprodi of + * their department, or staff roles may view a student's KRS. + */ + public function canAccessCourseRegistrationOf(Student $student): bool + { + if ($this->hasRole('dosen') || $this->hasRole('kaprodi')) { + return $this->isAdvisorOf($student) || $this->isKaprodiOf($student); + } + + return true; + } + + /** + * Only the student's academic advisor may approve/reject; the kaprodi + * signs but does not decide. Other staff roles retain override access. + */ + public function canReviewCourseRegistrationOf(Student $student): bool + { + if ($this->hasRole('dosen')) { + return $this->isAdvisorOf($student); + } + + if ($this->hasRole('kaprodi')) { + return false; + } + + return true; + } + + /** + * @return array + */ + public function ledDepartmentIds(): array + { + return $this->lecturer + ? $this->lecturer->leaderships()->whereNull('ended_at')->pluck('department_id')->all() + : []; + } } diff --git a/app/Services/Admin/Manage/CourseRegistrationService.php b/app/Services/Admin/Manage/CourseRegistrationService.php index 10fb997..99a13dc 100644 --- a/app/Services/Admin/Manage/CourseRegistrationService.php +++ b/app/Services/Admin/Manage/CourseRegistrationService.php @@ -3,14 +3,15 @@ namespace App\Services\Admin\Manage; use App\Enums\RegistrationStatus; +use App\Enums\StudentStatus; use App\Models\AcademicTerm; use App\Models\ClassEnrollment; -use App\Models\Course; use App\Models\CourseClass; use App\Models\CourseRegistration; use App\Models\CourseRegistrationSubmission; +use App\Models\Department; use App\Models\Student; -use App\Models\TuitionInvoice; +use App\Services\Admin\Master\AcademicTermService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; @@ -18,70 +19,210 @@ class CourseRegistrationService { - public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator + public function __construct( + private readonly AcademicTermService $academicTermService, + ) {} + + /** + * @return Collection + */ + public function departmentSummary(): Collection { - return CourseRegistrationSubmission::query() - ->select(['id', 'student_id', 'academic_term_id', 'status', 'signed_at', 'rejection_reason', 'reviewed_by', 'reviewed_at']) - ->with([ - 'student.user.profile', - 'student.department', - 'academicTerm:id,name,semester,start_date,end_date', - 'reviewer.profile', - 'courseRegistrations.courseClass.course:id,code,name', - 'logs' => fn ($q) => $q->latest(), - 'logs.actor.profile', - ]) - ->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%") - ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))) - ->when($status, fn ($q) => $q->where('status', $status)) - ->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId)) - ->latest() - ->paginate($perPage); + return Department::query() + ->select('id', 'name') + ->orderBy('name') + ->get(); } /** - * Admin-created registration: additively attach course classes to a - * student's submission for a term, without disturbing an existing - * submission's review status. - * - * @param array{student_id: int, academic_term_id: int, course_class_ids: array} $data + * @param array|null $ledDepartmentIds Restricts results to a kaprodi's own department(s). */ - public function create(array $data): CourseRegistrationSubmission + public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $advisorLecturerId = null, ?array $ledDepartmentIds = null): LengthAwarePaginator { - return DB::transaction(function () use ($data) { - $submission = CourseRegistrationSubmission::firstOrCreate( - [ - 'student_id' => $data['student_id'], - 'academic_term_id' => $data['academic_term_id'], - ], - [ - 'status' => RegistrationStatus::Submitted, - 'signed_at' => now(), - ], - ); + $paginator = Student::query() + ->select(['id', 'user_id', 'student_number', 'department_id']) + ->where('status', StudentStatus::Active) + ->when($ledDepartmentIds !== null, fn ($q) => $q->whereIn('department_id', $ledDepartmentIds)) + ->when($departmentId, fn ($q) => $q->where('department_id', $departmentId)) + ->when($advisorLecturerId, fn ($q) => $q->where('academic_advisor_id', $advisorLecturerId)) + ->when($search, fn ($q) => $q->where('student_number', 'like', "%{$search}%") + ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))) + ->with(['user.profile', 'department:id,name']) + ->orderBy('student_number') + ->paginate($perPage); - if ($submission->wasRecentlyCreated) { - $submission->logs()->create([ - 'status' => RegistrationStatus::Submitted, - 'actor_id' => auth()->id(), - ]); - } + return $paginator->through(fn (Student $student) => [ + 'student_id' => $student->id, + 'student' => $student, + ]); + } - foreach ($data['course_class_ids'] as $courseClassId) { + public function findOrCreateForStudent(Student $student, AcademicTerm $academicTerm, ?int $semesterNumber): CourseRegistrationSubmission + { + return CourseRegistrationSubmission::firstOrCreate( + [ + 'student_id' => $student->id, + 'academic_term_id' => $academicTerm->id, + 'semester_number' => $semesterNumber, + ], + [ + 'status' => RegistrationStatus::Submitted, + 'signed_at' => now(), + ], + ); + } + + public function findOrCreateForSemester(Student $student, string $semester): CourseRegistrationSubmission + { + $semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester); + $activeTerm = $this->academicTermService->getActive(); + + abort_if($activeTerm === null, 422); + + return $this->findOrCreateForStudent($student, $activeTerm, $semesterNumber); + } + + /** + * @return array + */ + public function buildSubmissionPayload(Student $student, ?AcademicTerm $activeTerm, ?int $semesterNumber, Collection $allCourseClasses): array + { + $submission = $this->findOrCreateForStudent($student, $activeTerm, $semesterNumber); + + $data = $this->withDetails($submission); + $payload = $data->toArray(); + + $registeredCourseClassIds = $data->courseRegistrations->pluck('course_class_id')->all(); + + $payload['has_registrations'] = ! empty($registeredCourseClassIds); + $payload['course_registrations'] = $allCourseClasses + ->filter(fn (CourseClass $courseClass) => $courseClass->course?->semester_number === $semesterNumber) + ->map(fn (CourseClass $courseClass) => [ + 'id' => $courseClass->id, + 'course_class' => ['id' => $courseClass->id, 'course' => $courseClass->course], + 'is_registered' => in_array($courseClass->id, $registeredCourseClassIds), + ]) + ->values(); + + return $payload; + } + + public function withDetails(CourseRegistrationSubmission $submission): CourseRegistrationSubmission + { + return $submission->load([ + 'student.user.profile', + 'student.department.currentLeader.lecturer.user.profile', + 'student.academicAdvisor.user.profile', + 'academicTerm:id,name,semester,start_date,end_date', + 'reviewer.profile', + 'courseRegistrations.courseClass.course', + 'logs' => fn ($q) => $q->oldest(), + 'logs.actor.profile', + ]); + } + + /** + * @return Collection + */ + public function availableCourseClasses(int $departmentId, int $academicTermId): Collection + { + return CourseClass::query() + ->where('academic_term_id', $academicTermId) + ->whereHas('course', fn ($q) => $q->where('department_id', $departmentId)) + ->with('course:id,code,name,credits,semester_number') + ->get(); + } + + /** + * @param array $openSemesters + * @return array + */ + public function selectableCourseClassIds(int $departmentId, int $academicTermId, ?int $semesterNumber, array $openSemesters): array + { + if ($semesterNumber === null || ! in_array($semesterNumber, $openSemesters, true)) { + return []; + } + + return CourseClass::query() + ->where('academic_term_id', $academicTermId) + ->whereHas('course', fn ($q) => $q->where('department_id', $departmentId)->where('semester_number', $semesterNumber)) + ->pluck('id') + ->all(); + } + + /** + * @param array $courseClassIds + */ + public function saveRegistrations( + CourseRegistrationSubmission $submission, + array $courseClassIds, + int $academicTermId, + int $studentId, + ?UploadedFile $signature, + ): CourseRegistrationSubmission { + abort_if($submission->status === RegistrationStatus::Approved, 403); + + return DB::transaction(function () use ($submission, $courseClassIds, $academicTermId, $studentId, $signature) { + $submission->courseRegistrations()->delete(); + + foreach ($courseClassIds as $courseClassId) { CourseRegistration::create([ - 'student_id' => $data['student_id'], - 'academic_term_id' => $data['academic_term_id'], + 'student_id' => $studentId, + 'academic_term_id' => $academicTermId, 'course_class_id' => $courseClassId, 'submission_id' => $submission->id, ]); } + $submission->update([ + 'status' => RegistrationStatus::Submitted, + 'rejection_reason' => null, + 'signed_at' => now(), + ]); + + if ($signature) { + $submission->addMedia($signature)->toMediaCollection('signature'); + } + + $submission->logs()->create([ + 'status' => RegistrationStatus::Submitted, + 'actor_id' => auth()->id(), + ]); + return $submission; }); } + public function signAsKaprodi(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission + { + $this->assertSignable($submission); + + $submission->addMedia($signature)->toMediaCollection('kaprodi_signature'); + + return $submission; + } + + public function signAsAdvisor(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission + { + $this->assertSignable($submission); + + $submission->addMedia($signature)->toMediaCollection('advisor_signature'); + + return $submission; + } + + private function assertSignable(CourseRegistrationSubmission $submission): void + { + abort_unless($submission->status === RegistrationStatus::Submitted, 403); + abort_unless($submission->courseRegistrations()->exists(), 403); + } + public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission { + abort_unless($submission->status === RegistrationStatus::Submitted, 403); + abort_if($submission->kaprodi_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.'); + abort_if($submission->advisor_signature_url === null, 422, 'Silakan tanda tangani KRS ini terlebih dahulu.'); + return DB::transaction(function () use ($submission) { $submission->update([ 'status' => RegistrationStatus::Approved, @@ -113,6 +254,8 @@ public function approve(CourseRegistrationSubmission $submission): CourseRegistr public function reject(CourseRegistrationSubmission $submission, string $reason): CourseRegistrationSubmission { + abort_unless($submission->status === RegistrationStatus::Submitted, 403); + return DB::transaction(function () use ($submission, $reason) { $submission->update([ 'status' => RegistrationStatus::Rejected, @@ -130,164 +273,4 @@ public function reject(CourseRegistrationSubmission $submission, string $reason) return $submission; }); } - - /** - * @return Collection - */ - public function availableCourseClasses(Student $student, AcademicTerm $term): Collection - { - $courses = Course::query() - ->where('department_id', $student->department_id) - ->where('semester_number', $student->current_semester) - ->orderBy('name') - ->get(['id', 'code', 'name', 'credits']); - - $classes = CourseClass::query() - ->whereIn('course_id', $courses->pluck('id')) - ->where('academic_term_id', $term->id) - ->with('lecturer.user.profile') - ->get() - ->keyBy('course_id'); - - return $courses->map(fn (Course $course) => [ - 'course' => $course, - 'course_class' => $classes->get($course->id), - ])->values(); - } - - /** - * @return array{has_invoice: bool, is_paid: bool, amount_due: ?float, paid_total: ?float} - */ - public function paymentStatus(Student $student, AcademicTerm $term): array - { - $invoice = TuitionInvoice::query() - ->where('student_id', $student->id) - ->where('academic_term_id', $term->id) - ->withSum('payments as paid_total', 'amount_paid') - ->first(); - - if (! $invoice) { - return [ - 'has_invoice' => false, - 'is_paid' => false, - 'amount_due' => null, - 'paid_total' => null, - ]; - } - - $paidTotal = (float) ($invoice->paid_total ?? 0); - $amountDue = (float) $invoice->amount_due; - - return [ - 'has_invoice' => true, - 'is_paid' => $paidTotal >= $amountDue, - 'amount_due' => $amountDue, - 'paid_total' => $paidTotal, - ]; - } - - public function currentSubmission(Student $student, AcademicTerm $term): ?CourseRegistrationSubmission - { - return CourseRegistrationSubmission::query() - ->where('student_id', $student->id) - ->where('academic_term_id', $term->id) - ->with([ - 'courseRegistrations.courseClass.course', - 'courseRegistrations.courseClass.lecturer.user.profile', - 'logs' => fn ($q) => $q->latest(), - 'logs.actor.profile', - ]) - ->first(); - } - - /** - * @return Collection - */ - public function submissionsFor(Student $student): Collection - { - return CourseRegistrationSubmission::query() - ->where('student_id', $student->id) - ->withCount('courseRegistrations') - ->with('academicTerm:id,name,semester,start_date,end_date') - ->latest() - ->get(); - } - - public function withDetails(CourseRegistrationSubmission $submission): CourseRegistrationSubmission - { - return $submission->load([ - 'student.user.profile', - 'student.department', - 'academicTerm:id,name,semester,start_date,end_date', - 'reviewer.profile', - 'courseRegistrations.courseClass.course', - 'courseRegistrations.courseClass.lecturer.user.profile', - 'logs' => fn ($q) => $q->oldest(), - 'logs.actor.profile', - ]); - } - - public function canSubmit(?CourseRegistrationSubmission $submission): bool - { - return $submission === null || $submission->status === RegistrationStatus::Rejected; - } - - /** - * @return array - */ - public function allowedCourseClassIds(Student $student, AcademicTerm $term): array - { - $courseIds = Course::query() - ->where('department_id', $student->department_id) - ->where('semester_number', $student->current_semester) - ->pluck('id'); - - return CourseClass::query() - ->whereIn('course_id', $courseIds) - ->where('academic_term_id', $term->id) - ->pluck('id') - ->all(); - } - - /** - * Student self-service sign-off: replaces the submission's course - * selection wholesale and attaches the captured signature. - * - * @param array $courseClassIds - */ - public function sign(Student $student, AcademicTerm $term, array $courseClassIds, UploadedFile $signatureFile): CourseRegistrationSubmission - { - return DB::transaction(function () use ($student, $term, $courseClassIds, $signatureFile) { - $submission = CourseRegistrationSubmission::updateOrCreate( - ['student_id' => $student->id, 'academic_term_id' => $term->id], - [ - 'status' => RegistrationStatus::Submitted, - 'signed_at' => now(), - 'rejection_reason' => null, - 'reviewed_by' => null, - 'reviewed_at' => null, - ], - ); - - $submission->courseRegistrations()->delete(); - - foreach ($courseClassIds as $courseClassId) { - CourseRegistration::create([ - 'student_id' => $student->id, - 'academic_term_id' => $term->id, - 'course_class_id' => $courseClassId, - 'submission_id' => $submission->id, - ]); - } - - $submission->addMedia($signatureFile)->toMediaCollection('signature'); - - $submission->logs()->create([ - 'status' => RegistrationStatus::Submitted, - 'actor_id' => $student->user_id, - ]); - - return $submission; - }); - } } diff --git a/app/Support/PermissionCatalog.php b/app/Support/PermissionCatalog.php index f36c1c0..a490bec 100644 --- a/app/Support/PermissionCatalog.php +++ b/app/Support/PermissionCatalog.php @@ -23,7 +23,7 @@ class PermissionCatalog public const MANAGE = [ 'view-course-classes', 'create-course-classes', 'update-course-classes', 'delete-course-classes', 'view-course-class-enrollments', 'create-course-class-enrollments', 'delete-course-class-enrollments', - 'view-course-registrations', 'create-course-registrations', 'approve-course-registrations', 'reject-course-registrations', + 'view-course-registrations', 'approve-course-registrations', 'reject-course-registrations', 'view-announcements', 'create-announcements', 'update-announcements', 'delete-announcements', ]; diff --git a/database/migrations/2026_08_24_235959_create_course_registration_submissions_table.php b/database/migrations/2026_08_24_235959_create_course_registration_submissions_table.php index 9e6bc1f..9e59d89 100644 --- a/database/migrations/2026_08_24_235959_create_course_registration_submissions_table.php +++ b/database/migrations/2026_08_24_235959_create_course_registration_submissions_table.php @@ -13,6 +13,7 @@ public function up(): void $table->id(); $table->foreignId('student_id')->constrained()->cascadeOnDelete(); $table->foreignId('academic_term_id')->constrained()->cascadeOnDelete(); + $table->integer('semester_number')->nullable(); $table->enum('status', RegistrationStatus::values())->default(RegistrationStatus::Submitted->value); $table->timestamp('signed_at'); $table->text('rejection_reason')->nullable(); @@ -20,7 +21,7 @@ public function up(): void $table->timestamp('reviewed_at')->nullable(); $table->timestamps(); - $table->unique(['student_id', 'academic_term_id'], 'course_reg_submissions_student_term_unique'); + $table->unique(['student_id', 'academic_term_id', 'semester_number'], 'course_reg_submissions_student_term_semester_unique'); }); } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9f77eb1..ccd2b6f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -20,14 +20,14 @@ public function run(): void CourseSeeder::class, CourseClassSeeder::class, ClassEnrollmentSeeder::class, - CourseRegistrationSeeder::class, + // CourseRegistrationSeeder::class, MaterialSeeder::class, AssignmentSeeder::class, SubmissionSeeder::class, ScheduleSeeder::class, AttendanceSeeder::class, - TuitionInvoiceSeeder::class, - TuitionPaymentSeeder::class, + // TuitionInvoiceSeeder::class, + // TuitionPaymentSeeder::class, AnnouncementSeeder::class, LetterRequestSeeder::class, AcademicAdvisingLogSeeder::class, diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 1c3d853..6e5fff4 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -41,6 +41,7 @@ public function run(): void 'view-dashboard', 'view-academic-terms', 'view-courses', + 'view-course-registrations', 'view-letter-requests', 'create-letter-requests', 'update-letter-requests', @@ -52,6 +53,9 @@ public function run(): void 'view-courses', 'view-academic-advising-logs', 'view-students', + 'view-course-registrations', + 'approve-course-registrations', + 'reject-course-registrations', ...$feedbackSelfService, ], 'staff-admin' => [ diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index c47d08c..cf7af54 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -54,16 +54,11 @@ import { index as letterRequestsRoute } from '@/routes/admin/services/letter-req import { index as administratorsRoute } from '@/routes/admin/users/administrators'; import { index as lecturersRoute } from '@/routes/admin/users/lecturers'; import { index as studentsRoute } from '@/routes/admin/users/students'; -import { index as studentCourseRegistrationsRoute } from '@/routes/student/course-registrations'; import type { Auth } from '@/types/auth'; -const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi']; - function buildNavMain({ - isMahasiswa, permissions, }: { - isMahasiswa: boolean; permissions: string[]; }): (NavGroup | NavItem)[] { const can = (permission: string) => permissions.includes(permission); @@ -117,13 +112,11 @@ function buildNavMain({ ]; const kelolaItems: NavItem[] = [ - ...(isMahasiswa || can('view-course-registrations') + ...(can('view-course-registrations') ? [ { name: 'Registrasi KRS', - url: isMahasiswa - ? studentCourseRegistrationsRoute.url() - : courseRegistrationsRoute.url(), + url: courseRegistrationsRoute.url(), icon: FileCheck2, }, ] @@ -289,14 +282,8 @@ function buildNavSecondary( export function AppSidebar({ ...props }: React.ComponentProps) { const { name, auth, pendingFeedbackCount } = usePage<{ auth: Auth }>() .props; - const roleNames = auth?.user?.roles?.map((role) => role.name) ?? []; - const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role)); - const isMahasiswa = !isStaff && roleNames.includes('mahasiswa'); const permissions = auth?.permissions ?? []; - const navMain = buildNavMain({ - isMahasiswa, - permissions, - }); + const navMain = buildNavMain({ permissions }); return ( diff --git a/resources/js/components/signature-pad.tsx b/resources/js/components/signature-pad.tsx index 8888cf0..18560ad 100644 --- a/resources/js/components/signature-pad.tsx +++ b/resources/js/components/signature-pad.tsx @@ -1,5 +1,5 @@ import { Eraser } from 'lucide-react'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import SignatureCanvasImport from 'react-signature-canvas'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; @@ -18,12 +18,49 @@ const SignatureCanvas = (( type SignaturePadProps = { name: string; error?: string; + /** Renders a small, chrome-free pad sized to sit inline with other + * compact signature placeholders (e.g. a document's signature row). */ + compact?: boolean; + /** Fires with the captured signature file whenever the drawing changes, + * for callers that submit it outside a native
(e.g. via router). */ + onCapture?: (file: File | null) => void; }; -export function SignaturePad({ name, error }: SignaturePadProps) { +export function SignaturePad({ + name, + error, + compact, + onCapture, +}: SignaturePadProps) { const padRef = useRef(null); const fileInputRef = useRef(null); + // The canvas element's drawing-surface resolution (width/height + // attributes) does not automatically follow its CSS-rendered size, so + // without this, pointer coordinates drift from where ink is drawn — + // worse on HiDPI screens. Size the backing buffer to match on mount and + // whenever the layout might change. + useEffect(() => { + function resizeCanvas() { + const canvas = padRef.current?.getCanvas(); + + if (!canvas) { + return; + } + + const ratio = Math.max(window.devicePixelRatio || 1, 1); + canvas.width = canvas.offsetWidth * ratio; + canvas.height = canvas.offsetHeight * ratio; + canvas.getContext('2d')?.scale(ratio, ratio); + padRef.current?.clear(); + } + + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + return () => window.removeEventListener('resize', resizeCanvas); + }, []); + function syncFileInput() { const pad = padRef.current; const input = fileInputRef.current; @@ -43,6 +80,7 @@ export function SignaturePad({ name, error }: SignaturePadProps) { const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); input.files = dataTransfer.files; + onCapture?.(file); }, 'image/png'); } @@ -52,6 +90,41 @@ export function SignaturePad({ name, error }: SignaturePadProps) { if (fileInputRef.current) { fileInputRef.current.value = ''; } + + onCapture?.(null); + } + + if (compact) { + return ( +
+
+ + +
+ + +
+ ); } return ( diff --git a/resources/js/components/ui/tabs.tsx b/resources/js/components/ui/tabs.tsx index b463afd..7f2c4f2 100644 --- a/resources/js/components/ui/tabs.tsx +++ b/resources/js/components/ui/tabs.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Tabs as TabsPrimitive } from "radix-ui" diff --git a/resources/js/pages/admin/manage/course-registrations/columns.tsx b/resources/js/pages/admin/manage/course-registrations/columns.tsx index b4627d3..49f6edf 100644 --- a/resources/js/pages/admin/manage/course-registrations/columns.tsx +++ b/resources/js/pages/admin/manage/course-registrations/columns.tsx @@ -1,25 +1,12 @@ import type { ColumnDef } from '@tanstack/react-table'; -import { Check, Eye, X } from 'lucide-react'; +import { Eye } from 'lucide-react'; import { RowActions } from '@/components/row-actions'; -import { Badge } from '@/components/ui/badge'; import { show } from '@/routes/admin/manage/course-registrations'; -import type { CourseRegistrationSubmission } from '@/types/course-registration'; -import { RegistrationStatusLabels } from '@/types/course-registration'; +import type { CourseRegistrationRow } from '@/types/course-registration'; -export type { CourseRegistrationSubmission } from '@/types/course-registration'; - -type CreateColumnsParams = { - handleApprove: (submission: CourseRegistrationSubmission) => void; - handleRejectClick: (submission: CourseRegistrationSubmission) => void; - canApprove: boolean; - canReject: boolean; -}; - -export function createCourseRegistrationColumns( - params: CreateColumnsParams, -): ColumnDef[] { - const { handleApprove, handleRejectClick, canApprove, canReject } = params; +export type { CourseRegistrationRow } from '@/types/course-registration'; +export function createCourseRegistrationColumns(): ColumnDef[] { return [ { id: 'student', @@ -33,98 +20,35 @@ export function createCourseRegistrationColumns( {student?.user?.profile?.full_name ?? 'N/A'}

- {student?.student_number} ·{' '} - {student?.department?.name ?? '-'} + {student?.student_number}

); }, }, { - id: 'semester', - header: () => Semester, - meta: { - className: 'w-[100px] text-center', - headerClassName: 'w-[100px] text-center', - }, - cell: ({ row }) => row.original.student?.current_semester ?? '-', - }, - { - id: 'count', - header: () => Jumlah, - meta: { - className: 'w-[100px] text-center', - headerClassName: 'w-[100px] text-center', - }, - cell: ({ row }) => row.original.course_registrations.length, - }, - { - accessorKey: 'status', - header: () => Status, - meta: { - className: 'w-[140px] text-center', - headerClassName: 'w-[140px] text-center', - }, - cell: ({ row }) => { - const status = row.original.status; - - return ( -
- - {RegistrationStatusLabels[status]} - -
- ); - }, + id: 'department', + header: () => Jurusan, + cell: ({ row }) => row.original.student?.department?.name ?? '-', }, { id: 'actions', header: () => Aksi, meta: { - className: 'w-[130px] text-center', - headerClassName: 'w-[130px] text-center', - }, - cell: ({ row }) => { - const submission = row.original; - - return ( - , - href: show.url(submission.id), - }, - { - label: 'Setujui', - icon: , - iconClassName: 'text-primary', - show: - submission.status === 'submitted' && - canApprove, - onClick: () => handleApprove(submission), - }, - { - label: 'Tolak', - icon: , - iconClassName: 'text-destructive', - show: - submission.status === 'submitted' && - canReject, - onClick: () => handleRejectClick(submission), - }, - ]} - /> - ); + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', }, + cell: ({ row }) => ( + , + href: show.url(row.original.student_id), + }, + ]} + /> + ), }, ]; } diff --git a/resources/js/pages/admin/manage/course-registrations/index.tsx b/resources/js/pages/admin/manage/course-registrations/index.tsx index afa1405..8eaff7e 100644 --- a/resources/js/pages/admin/manage/course-registrations/index.tsx +++ b/resources/js/pages/admin/manage/course-registrations/index.tsx @@ -1,118 +1,43 @@ -import { Head, router } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; -import { useState } from 'react'; +import { Head } from '@inertiajs/react'; import type { PaginationState } from '@/components/data-table'; import { DataTable } from '@/components/data-table'; import type { FilterField } from '@/components/filter-dialog'; import { FilterDialog } from '@/components/filter-dialog'; -import { FormDialog } from '@/components/form-dialog'; -import InputError from '@/components/input-error'; import { PageHeader } from '@/components/page-header'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Combobox, - ComboboxContent, - ComboboxEmpty, - ComboboxInput, - ComboboxItem, - ComboboxList, -} from '@/components/ui/combobox'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; -import { usePermissions } from '@/hooks/use-permissions'; import { useServerTable } from '@/hooks/use-server-table'; -import { - index as courseRegistrationIndex, - approve, - reject, - store, -} from '@/routes/admin/manage/course-registrations'; -import { formatAcademicTermLabel } from '@/types/academic-term'; +import { index } from '@/routes/admin/manage/course-registrations'; import type { - CourseRegistrationCourseClass, - CourseRegistrationStudent, - CourseRegistrationSubmission, -} from '@/types/course-registration'; -import { - RegistrationStatuses, - RegistrationStatusLabels, + CourseRegistrationRow, + DepartmentSummary, } from '@/types/course-registration'; import { createCourseRegistrationColumns } from './columns'; -type AcademicTermOption = { - id: number; - name: string; - semester: string; - is_active: boolean; -}; - type Props = { + departments: DepartmentSummary[]; registrations: { - data: CourseRegistrationSubmission[]; + data: CourseRegistrationRow[]; current_page: number; last_page: number; per_page: number; total: number; }; - students: CourseRegistrationStudent[]; - academicTerms: AcademicTermOption[]; - courseClasses: CourseRegistrationCourseClass[]; - highlight?: number; filters: { - status?: string; - academic_term_id?: string; + department_id?: string; }; }; -function studentLabel(student: CourseRegistrationStudent): string { - return `${student.user?.profile?.full_name ?? 'N/A'} - ${student.student_number}`; -} - -function courseClassLabel(courseClass: CourseRegistrationCourseClass): string { - const course = courseClass.course; - - return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}`; -} - export default function CourseRegistrationIndex({ + departments, registrations, - students, - academicTerms, - courseClasses, - highlight, filters, }: Props) { - const [createOpen, setCreateOpen] = useState(false); - const [rejecting, setRejecting] = - useState(null); - const { hasPermission } = usePermissions(); - const canCreate = hasPermission('create-course-registrations'); - const canApprove = hasPermission('approve-course-registrations'); - const canReject = hasPermission('reject-course-registrations'); - const filterFields: FilterField[] = [ { - key: 'status', - label: 'Status', - options: RegistrationStatuses.map((status) => ({ - value: status, - label: RegistrationStatusLabels[status], - })), - }, - { - key: 'academic_term_id', - label: 'Periode Akademik', - options: academicTerms.map((term) => ({ - value: String(term.id), - label: formatAcademicTermLabel(term), + key: 'department_id', + label: 'Jurusan', + options: departments.map((department) => ({ + value: String(department.id), + label: department.name, })), }, ]; @@ -131,58 +56,19 @@ export default function CourseRegistrationIndex({ handleSearchChange, applyFilters, } = useServerTable({ - route: () => courseRegistrationIndex.url(), + route: () => index.url(), pagination, filters, }); - function handleApprove(submission: CourseRegistrationSubmission) { - router.patch(approve(submission.id)); - } - - const columns = createCourseRegistrationColumns({ - handleApprove, - handleRejectClick: (submission) => setRejecting(submission), - canApprove, - canReject, - }); + const columns = createCourseRegistrationColumns(); return ( <>
- - Menampilkan registrasi dari notifikasi. -

- ) - } - actions={ - canCreate && ( - - - ) - } - /> - - + } /> - - { - if (!open) { - setRejecting(null); - } - }} - submission={rejecting} - />
); } - -function RejectForm({ - open, - onOpenChange, - submission, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - submission: CourseRegistrationSubmission | null; -}) { - return ( - onOpenChange(false)} - > - {({ errors }) => ( -
-

- KRS milik{' '} - - {submission?.student?.user?.profile?.full_name ?? - 'mahasiswa ini'} - {' '} - akan ditolak. Mahasiswa perlu memperbaiki dan mengajukan - ulang. -

-
- -