diff --git a/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php b/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php index 677e958..6d91687 100644 --- a/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php +++ b/app/Http/Controllers/Admin/Manage/CourseRegistrationController.php @@ -4,14 +4,16 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Manage\CourseRegistrationRequest; +use App\Http\Requests\Admin\Manage\RejectCourseRegistrationRequest; use App\Http\Requests\PaginatedRequest; -use App\Models\CourseRegistration; +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\LecturerService; use App\Services\Admin\Users\StudentService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -22,7 +24,6 @@ public function __construct( private readonly StudentService $studentService, private readonly AcademicTermService $academicTermService, private readonly CourseClassService $courseClassService, - private readonly LecturerService $lecturerService, ) {} public function index(PaginatedRequest $request): Response @@ -36,13 +37,84 @@ public function index(PaginatedRequest $request): Response 'students' => $this->studentService->getAllForSelect(), 'academicTerms' => $this->academicTermService->getAllForSelect(), 'courseClasses' => $this->courseClassService->getAllForSelect(), - 'lecturers' => $this->lecturerService->getAllForSelect(), 'filters' => $request->only(['status', 'academic_term_id']), ]); } + public function mine(Request $request): Response + { + $student = $this->currentStudent($request); + + $term = $this->academicTermService->getActive(); + $payment = $term ? $this->service->paymentStatus($student, $term) : null; + $activeSubmission = $term ? $this->service->currentSubmission($student, $term) : null; + + return Inertia::render('student/course-registrations/index', [ + 'academicTerm' => $term, + 'payment' => $payment, + 'canSubmit' => $this->service->canSubmit($activeSubmission), + 'activeSubmission' => $activeSubmission, + 'submissions' => $this->service->submissionsFor($student), + ]); + } + + 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), + ]); + } + public function store(CourseRegistrationRequest $request): RedirectResponse { + if ($request->routeIs('student.*')) { + $student = $this->currentStudent($request); + $term = $this->academicTermService->getActive(); + + $submission = $this->service->sign( + $student, + $term, + $request->validated('course_class_ids'), + $request->file('signature'), + ); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditandatangani.']); + + return to_route('student.course-registrations.show', $submission); + } + $this->service->create($request->validated()); Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']); @@ -50,19 +122,26 @@ public function store(CourseRegistrationRequest $request): RedirectResponse return to_route('admin.manage.course-registrations.index'); } - public function update(CourseRegistrationRequest $request, CourseRegistration $courseRegistration): RedirectResponse + public function approve(CourseRegistrationSubmission $submission): RedirectResponse { - $this->service->update($courseRegistration, $request->validated()); + $this->service->approve($submission); - Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil diperbarui.']); - - return to_route('admin.manage.course-registrations.index'); + return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disetujui.'])->back(); } - public function destroy(CourseRegistration $courseRegistration): RedirectResponse + public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse { - $this->service->delete($courseRegistration); + $this->service->reject($submission, $request->validated('reason')); - return Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil dihapus.'])->back(); + 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/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 9118ed5..fb40980 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -41,7 +41,7 @@ public function share(Request $request): array ...parent::share($request), 'name' => config('app.name'), 'auth' => [ - 'user' => $request->user(), + 'user' => $request->user()?->load('roles:id,name'), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'unreadNotificationsCount' => fn () => $request->user() diff --git a/app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php b/app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php index 668d106..8e17d5f 100644 --- a/app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php +++ b/app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php @@ -2,7 +2,8 @@ namespace App\Http\Requests\Admin\Manage; -use App\Enums\RegistrationStatus; +use App\Services\Admin\Manage\CourseRegistrationService; +use App\Services\Admin\Master\AcademicTermService; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -10,40 +11,65 @@ class CourseRegistrationRequest extends FormRequest { public function authorize(): bool { - return true; + if (! $this->routeIs('student.*')) { + return true; + } + + $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 { - $registration = $this->route('course_registration'); + 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_id' => [ - 'required', + 'course_class_ids' => ['required', 'array', 'min:1'], + 'course_class_ids.*' => [ 'integer', Rule::exists('course_classes', 'id'), - Rule::unique('course_registrations') - ->where('student_id', $this->input('student_id')) - ->ignore($registration?->id), + Rule::unique('course_registrations', 'course_class_id') + ->where('student_id', $this->input('student_id')), ], - 'status' => ['nullable', 'string', Rule::in(RegistrationStatus::values())], - 'approved_by' => ['nullable', 'integer', Rule::exists('lecturers', 'id')], ]; } public function attributes(): array { return [ - 'course_class_id' => 'kelas mata kuliah', - ]; - } - - public function messages(): array - { - return [ - 'course_class_id.unique' => 'Mahasiswa ini sudah terdaftar pada kelas mata kuliah tersebut.', + 'course_class_ids' => 'mata kuliah', ]; } } diff --git a/app/Http/Requests/Admin/Manage/RejectCourseRegistrationRequest.php b/app/Http/Requests/Admin/Manage/RejectCourseRegistrationRequest.php new file mode 100644 index 0000000..846f4a6 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/RejectCourseRegistrationRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'reason.required' => 'Alasan penolakan wajib diisi.', + ]; + } +} diff --git a/app/Models/CourseRegistration.php b/app/Models/CourseRegistration.php index 03f1e0b..b836b13 100644 --- a/app/Models/CourseRegistration.php +++ b/app/Models/CourseRegistration.php @@ -2,7 +2,6 @@ namespace App\Models; -use App\Enums\RegistrationStatus; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -13,13 +12,6 @@ class CourseRegistration extends Model { use HasFactory; - protected function casts(): array - { - return [ - 'status' => RegistrationStatus::class, - ]; - } - public function student(): BelongsTo { return $this->belongsTo(Student::class); @@ -35,8 +27,8 @@ public function courseClass(): BelongsTo return $this->belongsTo(CourseClass::class); } - public function approver(): BelongsTo + public function submission(): BelongsTo { - return $this->belongsTo(Lecturer::class, 'approved_by'); + return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id'); } } diff --git a/app/Models/CourseRegistrationSubmission.php b/app/Models/CourseRegistrationSubmission.php new file mode 100644 index 0000000..a97c9b9 --- /dev/null +++ b/app/Models/CourseRegistrationSubmission.php @@ -0,0 +1,66 @@ + RegistrationStatus::class, + 'signed_at' => 'datetime', + 'reviewed_at' => 'datetime', + ]; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('signature')->singleFile(); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function academicTerm(): BelongsTo + { + return $this->belongsTo(AcademicTerm::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function courseRegistrations(): HasMany + { + return $this->hasMany(CourseRegistration::class, 'submission_id'); + } + + public function logs(): HasMany + { + return $this->hasMany(CourseRegistrationSubmissionLog::class, 'submission_id'); + } + + protected function signatureUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMediaUrl('signature') ?: null, + ); + } +} diff --git a/app/Models/CourseRegistrationSubmissionLog.php b/app/Models/CourseRegistrationSubmissionLog.php new file mode 100644 index 0000000..7bf523c --- /dev/null +++ b/app/Models/CourseRegistrationSubmissionLog.php @@ -0,0 +1,29 @@ + RegistrationStatus::class, + ]; + } + + public function submission(): BelongsTo + { + return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id'); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } +} diff --git a/app/Models/Student.php b/app/Models/Student.php index 649c453..af8e842 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -62,6 +62,11 @@ public function courseRegistrations(): HasMany return $this->hasMany(CourseRegistration::class); } + public function courseRegistrationSubmissions(): HasMany + { + return $this->hasMany(CourseRegistrationSubmission::class); + } + public function letterRequests(): HasMany { return $this->hasMany(LetterRequest::class); diff --git a/app/Services/Admin/Manage/CourseClassService.php b/app/Services/Admin/Manage/CourseClassService.php index 587137d..6640039 100644 --- a/app/Services/Admin/Manage/CourseClassService.php +++ b/app/Services/Admin/Manage/CourseClassService.php @@ -10,8 +10,8 @@ class CourseClassService { public function getAllForSelect(): Collection { - return CourseClass::select(['id', 'course_id']) - ->with('course:id,code,name') + return CourseClass::select(['id', 'course_id', 'academic_term_id']) + ->with('course:id,code,name,semester_number,department_id') ->get(); } diff --git a/app/Services/Admin/Manage/CourseRegistrationService.php b/app/Services/Admin/Manage/CourseRegistrationService.php index ec3e0c4..10fb997 100644 --- a/app/Services/Admin/Manage/CourseRegistrationService.php +++ b/app/Services/Admin/Manage/CourseRegistrationService.php @@ -3,22 +3,33 @@ namespace App\Services\Admin\Manage; use App\Enums\RegistrationStatus; +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\Student; +use App\Models\TuitionInvoice; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; class CourseRegistrationService { public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator { - return CourseRegistration::query() - ->select(['id', 'student_id', 'academic_term_id', 'course_class_id', 'status', 'approved_by', 'created_at']) + 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', - 'courseClass.course:id,code,name', - 'approver.user.profile', + '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}%")))) @@ -28,54 +39,255 @@ public function paginated(int $perPage = 25, string $search = '', ?string $statu ->paginate($perPage); } - public function create(array $data): CourseRegistration + /** + * 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 + */ + public function create(array $data): CourseRegistrationSubmission { - $registration = CourseRegistration::create([ - 'student_id' => $data['student_id'], - 'academic_term_id' => $data['academic_term_id'], - 'course_class_id' => $data['course_class_id'], - 'status' => $data['status'] ?? RegistrationStatus::Submitted->value, - 'approved_by' => $data['approved_by'] ?? null, - ]); + 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(), + ], + ); - $this->syncEnrollment($registration); + if ($submission->wasRecentlyCreated) { + $submission->logs()->create([ + 'status' => RegistrationStatus::Submitted, + 'actor_id' => auth()->id(), + ]); + } - return $registration; + foreach ($data['course_class_ids'] as $courseClassId) { + CourseRegistration::create([ + 'student_id' => $data['student_id'], + 'academic_term_id' => $data['academic_term_id'], + 'course_class_id' => $courseClassId, + 'submission_id' => $submission->id, + ]); + } + + return $submission; + }); } - public function update(CourseRegistration $registration, array $data): CourseRegistration + public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission { - $registration->student_id = $data['student_id']; - $registration->academic_term_id = $data['academic_term_id']; - $registration->course_class_id = $data['course_class_id']; - $registration->status = $data['status'] ?? RegistrationStatus::Submitted->value; - $registration->approved_by = $data['approved_by'] ?? null; - $registration->update(); + return DB::transaction(function () use ($submission) { + $submission->update([ + 'status' => RegistrationStatus::Approved, + 'rejection_reason' => null, + 'reviewed_by' => auth()->id(), + 'reviewed_at' => now(), + ]); - $this->syncEnrollment($registration); + $submission->logs()->create([ + 'status' => RegistrationStatus::Approved, + 'actor_id' => auth()->id(), + ]); - return $registration; + foreach ($submission->courseRegistrations as $registration) { + ClassEnrollment::firstOrCreate( + [ + 'course_class_id' => $registration->course_class_id, + 'student_id' => $registration->student_id, + ], + [ + 'enrolled_at' => now(), + ], + ); + } + + return $submission; + }); } - public function delete(CourseRegistration $registration): bool + public function reject(CourseRegistrationSubmission $submission, string $reason): CourseRegistrationSubmission { - return $registration->delete(); + return DB::transaction(function () use ($submission, $reason) { + $submission->update([ + 'status' => RegistrationStatus::Rejected, + 'rejection_reason' => $reason, + 'reviewed_by' => auth()->id(), + 'reviewed_at' => now(), + ]); + + $submission->logs()->create([ + 'status' => RegistrationStatus::Rejected, + 'reason' => $reason, + 'actor_id' => auth()->id(), + ]); + + return $submission; + }); } - private function syncEnrollment(CourseRegistration $registration): void + /** + * @return Collection + */ + public function availableCourseClasses(Student $student, AcademicTerm $term): Collection { - if ($registration->status !== RegistrationStatus::Approved) { - return; + $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, + ]; } - ClassEnrollment::firstOrCreate( - [ - 'course_class_id' => $registration->course_class_id, - 'student_id' => $registration->student_id, - ], - [ - 'enrolled_at' => now(), - ], - ); + $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/bootstrap/app.php b/bootstrap/app.php index ba5cf4c..bc4b18f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Request; +use Spatie\Permission\Middleware\RoleMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -22,6 +23,10 @@ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); + + $middleware->alias([ + 'role' => RoleMiddleware::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( 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 new file mode 100644 index 0000000..9e6bc1f --- /dev/null +++ b/database/migrations/2026_08_24_235959_create_course_registration_submissions_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->foreignId('academic_term_id')->constrained()->cascadeOnDelete(); + $table->enum('status', RegistrationStatus::values())->default(RegistrationStatus::Submitted->value); + $table->timestamp('signed_at'); + $table->text('rejection_reason')->nullable(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + $table->unique(['student_id', 'academic_term_id'], 'course_reg_submissions_student_term_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('course_registration_submissions'); + } +}; diff --git a/database/migrations/2026_08_25_000001_create_course_registrations_table.php b/database/migrations/2026_08_25_000001_create_course_registrations_table.php index d526544..e09a8bd 100644 --- a/database/migrations/2026_08_25_000001_create_course_registrations_table.php +++ b/database/migrations/2026_08_25_000001_create_course_registrations_table.php @@ -1,6 +1,5 @@ foreignId('student_id')->constrained()->cascadeOnDelete(); $table->foreignId('academic_term_id')->constrained()->cascadeOnDelete(); $table->foreignId('course_class_id')->constrained()->cascadeOnDelete(); - $table->enum('status', RegistrationStatus::values())->nullable()->default(RegistrationStatus::Submitted->value); - $table->foreignId('approved_by')->nullable()->constrained('lecturers')->nullOnDelete(); + $table->foreignId('submission_id')->constrained('course_registration_submissions')->cascadeOnDelete(); $table->timestamps(); $table->unique(['student_id', 'course_class_id']); diff --git a/database/migrations/2026_08_30_115855_create_course_registration_submission_logs_table.php b/database/migrations/2026_08_30_115855_create_course_registration_submission_logs_table.php new file mode 100644 index 0000000..641dfab --- /dev/null +++ b/database/migrations/2026_08_30_115855_create_course_registration_submission_logs_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('submission_id')->constrained('course_registration_submissions')->cascadeOnDelete(); + $table->enum('status', RegistrationStatus::values()); + $table->text('reason')->nullable(); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('course_registration_submission_logs'); + } +}; diff --git a/database/seeders/CourseRegistrationSeeder.php b/database/seeders/CourseRegistrationSeeder.php index 64d20aa..db46173 100644 --- a/database/seeders/CourseRegistrationSeeder.php +++ b/database/seeders/CourseRegistrationSeeder.php @@ -2,32 +2,80 @@ namespace Database\Seeders; +use App\Enums\RegistrationStatus; use App\Models\CourseRegistration; +use App\Models\CourseRegistrationSubmission; +use App\Models\CourseRegistrationSubmissionLog; use Illuminate\Database\Seeder; class CourseRegistrationSeeder extends Seeder { public function run(): void { - CourseRegistration::insert([ + $approved = CourseRegistrationSubmission::create([ + 'student_id' => 1, + 'academic_term_id' => 2, + 'status' => RegistrationStatus::Approved, + 'signed_at' => '2026-01-20 08:00:00', + 'reviewed_by' => 1, + 'reviewed_at' => '2026-01-20 13:00:00', + 'created_at' => '2026-01-20 08:00:00', + 'updated_at' => '2026-01-20 13:00:00', + ]); + + CourseRegistration::create([ + 'student_id' => 1, + 'academic_term_id' => 2, + 'course_class_id' => 1, + 'submission_id' => $approved->id, + 'created_at' => '2026-01-20 08:00:00', + 'updated_at' => '2026-01-20 08:00:00', + ]); + + CourseRegistrationSubmissionLog::insert([ [ - 'student_id' => 1, - 'academic_term_id' => 2, - 'course_class_id' => 1, - 'status' => 'approved', - 'approved_by' => 1, + 'submission_id' => $approved->id, + 'status' => RegistrationStatus::Submitted->value, + 'reason' => null, + 'actor_id' => 1, 'created_at' => '2026-01-20 08:00:00', + 'updated_at' => '2026-01-20 08:00:00', + ], + [ + 'submission_id' => $approved->id, + 'status' => RegistrationStatus::Approved->value, + 'reason' => null, + 'actor_id' => 1, + 'created_at' => '2026-01-20 13:00:00', 'updated_at' => '2026-01-20 13:00:00', ], - [ - 'student_id' => 2, - 'academic_term_id' => 2, - 'course_class_id' => 2, - 'status' => 'submitted', - 'approved_by' => null, - 'created_at' => '2026-01-20 08:10:00', - 'updated_at' => '2026-01-20 08:10:00', - ], + ]); + + $submitted = CourseRegistrationSubmission::create([ + 'student_id' => 2, + 'academic_term_id' => 2, + 'status' => RegistrationStatus::Submitted, + 'signed_at' => '2026-01-20 08:10:00', + 'created_at' => '2026-01-20 08:10:00', + 'updated_at' => '2026-01-20 08:10:00', + ]); + + CourseRegistration::create([ + 'student_id' => 2, + 'academic_term_id' => 2, + 'course_class_id' => 2, + 'submission_id' => $submitted->id, + 'created_at' => '2026-01-20 08:10:00', + 'updated_at' => '2026-01-20 08:10:00', + ]); + + CourseRegistrationSubmissionLog::create([ + 'submission_id' => $submitted->id, + 'status' => RegistrationStatus::Submitted->value, + 'reason' => null, + 'actor_id' => 2, + 'created_at' => '2026-01-20 08:10:00', + 'updated_at' => '2026-01-20 08:10:00', ]); } } diff --git a/docs/reference/KRS.xlsx b/docs/reference/KRS.xlsx new file mode 100644 index 0000000..b851a37 Binary files /dev/null and b/docs/reference/KRS.xlsx differ diff --git a/package-lock.json b/package-lock.json index 33e81f0..26bc0b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "react": "^19.2.0", "react-day-picker": "^10.0.1", "react-dom": "^19.2.0", + "react-signature-canvas": "^1.0.7", "recharts": "3.8.0", "shadcn": "^4.16.1", "sonner": "^2.0.0", @@ -70,6 +71,7 @@ "@laravel/vite-plugin-wayfinder": "^0.1.3", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^22.13.5", + "@types/react-signature-canvas": "^1.0.7", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.1", @@ -4335,6 +4337,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-signature-canvas": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/react-signature-canvas/-/react-signature-canvas-1.0.7.tgz", + "integrity": "sha512-0ulzaUvcIQ0HdNB5fHj+KE7ztWhlhYRsi65TdPIRj/t+FD5Rr8NJKBv4/xLViz7HsUh/tgqsoyKeARrm9+gPIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@types/signature_pad": "<3" + } + }, + "node_modules/@types/signature_pad": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz", + "integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -9137,7 +9157,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -10231,7 +10250,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -10674,6 +10692,21 @@ } } }, + "node_modules/react-signature-canvas": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.0.7.tgz", + "integrity": "sha512-yo0x0uTMVmcClaqryuQu6F8xEVapk5zdM9/nuQ7GalDJWccoVNZPMmCFGK7U5r3I0mrEHPB9E5/rFSYUgZcfmA==", + "license": "Apache-2.0", + "dependencies": { + "signature_pad": "^2.3.2", + "trim-canvas": "^0.1.0" + }, + "peerDependencies": { + "prop-types": "^15.5.8", + "react": "0.14 - 19", + "react-dom": "0.14 - 19" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -11383,6 +11416,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/signature_pad": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz", + "integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11809,6 +11848,12 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-canvas": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz", + "integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==", + "license": "Apache-2.0" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/package.json b/package.json index 3b17bfb..d65fc07 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@laravel/vite-plugin-wayfinder": "^0.1.3", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^22.13.5", + "@types/react-signature-canvas": "^1.0.7", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.1", @@ -78,6 +79,7 @@ "react": "^19.2.0", "react-day-picker": "^10.0.1", "react-dom": "^19.2.0", + "react-signature-canvas": "^1.0.7", "recharts": "3.8.0", "shadcn": "^4.16.1", "sonner": "^2.0.0", diff --git a/resources/js/components/signature-pad.tsx b/resources/js/components/signature-pad.tsx new file mode 100644 index 0000000..8888cf0 --- /dev/null +++ b/resources/js/components/signature-pad.tsx @@ -0,0 +1,97 @@ +import { Eraser } from 'lucide-react'; +import { useRef } from 'react'; +import SignatureCanvasImport from 'react-signature-canvas'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; + +// react-signature-canvas ships as a CommonJS/UMD bundle. Vite's dev-server +// pre-bundling wraps its `module.exports` (itself `{ __esModule: true, +// default: SignatureCanvas }`) as the ESM default export, so the default +// import resolves one layer too shallow. Unwrap it before use. +const SignatureCanvas = (( + SignatureCanvasImport as unknown as { + default?: typeof SignatureCanvasImport; + } +).default ?? SignatureCanvasImport) as typeof SignatureCanvasImport; + +type SignaturePadProps = { + name: string; + error?: string; +}; + +export function SignaturePad({ name, error }: SignaturePadProps) { + const padRef = useRef(null); + const fileInputRef = useRef(null); + + function syncFileInput() { + const pad = padRef.current; + const input = fileInputRef.current; + + if (!pad || !input || pad.isEmpty()) { + return; + } + + pad.getTrimmedCanvas().toBlob((blob: Blob | null) => { + if (!blob) { + return; + } + + const file = new File([blob], 'signature.png', { + type: 'image/png', + }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + input.files = dataTransfer.files; + }, 'image/png'); + } + + function handleClear() { + padRef.current?.clear(); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + + return ( +
+
+ + +
+
+ +
+ +

+ Gambar tanda tangan Anda pada kotak di atas menggunakan mouse + atau layar sentuh. +

+ +
+ ); +} diff --git a/resources/js/pages/admin/manage/course-registrations/columns.tsx b/resources/js/pages/admin/manage/course-registrations/columns.tsx index 47a9b98..db7b39d 100644 --- a/resources/js/pages/admin/manage/course-registrations/columns.tsx +++ b/resources/js/pages/admin/manage/course-registrations/columns.tsx @@ -1,30 +1,26 @@ import type { ColumnDef } from '@tanstack/react-table'; -import { format } from 'date-fns'; -import { Pencil, Trash2 } from 'lucide-react'; +import { Check, Eye, X } from 'lucide-react'; import { RowActions } from '@/components/row-actions'; import { Badge } from '@/components/ui/badge'; -import { formatAcademicTermLabel } from '@/types/academic-term'; -import type { - CourseRegistration, - RegistrationStatus, -} from '@/types/course-registration'; +import { show } from '@/routes/admin/manage/course-registrations'; +import type { CourseRegistrationSubmission } from '@/types/course-registration'; import { RegistrationStatusLabels } from '@/types/course-registration'; -export type { CourseRegistration } from '@/types/course-registration'; +export type { CourseRegistrationSubmission } from '@/types/course-registration'; type CreateColumnsParams = { - handleEdit: (registration: CourseRegistration) => void; - handleDeleteClick: (registration: CourseRegistration) => void; + handleApprove: (submission: CourseRegistrationSubmission) => void; + handleRejectClick: (submission: CourseRegistrationSubmission) => void; }; export function createCourseRegistrationColumns( params: CreateColumnsParams, -): ColumnDef[] { - const { handleEdit, handleDeleteClick } = params; +): ColumnDef[] { + const { handleApprove, handleRejectClick } = params; return [ { - accessorKey: 'student.student_number', + id: 'student', header: () => Mahasiswa, cell: ({ row }) => { const student = row.original.student; @@ -43,101 +39,86 @@ export function createCourseRegistrationColumns( }, }, { - accessorKey: 'course_class.course.name', - header: () => Kelas Mata Kuliah, - cell: ({ row }) => { - const courseClass = row.original.course_class; - - return ( -
-

- {courseClass?.course?.name ?? 'N/A'} -

-

- {courseClass?.course?.code} -

-
- ); + id: 'semester', + header: () => Semester, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', }, + cell: ({ row }) => row.original.student?.current_semester ?? '-', }, { - accessorKey: 'academic_term.name', - header: () => Periode, - cell: ({ row }) => { - const term = row.original.academic_term; - - return term ? formatAcademicTermLabel(term) : '-'; + 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-[130px] text-center', - headerClassName: 'w-[130px] text-center', + className: 'w-[140px] text-center', + headerClassName: 'w-[140px] text-center', }, cell: ({ row }) => { - const status = row.getValue( - 'status', - ) as RegistrationStatus | null; - - const variant = - status === 'approved' - ? 'default' - : status === 'rejected' - ? 'destructive' - : 'outline'; + const status = row.original.status; return (
- {status ? ( - - {RegistrationStatusLabels[status]} - - ) : ( - - - )} + + {RegistrationStatusLabels[status]} +
); }, }, - { - accessorKey: 'approver.user.profile.full_name', - header: () => Disetujui Oleh, - cell: ({ row }) => - row.original.approver?.user?.profile?.full_name ?? '-', - }, - { - accessorKey: 'created_at', - header: () => Diajukan, - cell: ({ row }) => - format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'), - }, { id: 'actions', header: () => Aksi, meta: { - className: 'w-[100px] text-center', - headerClassName: 'w-[100px] text-center', + 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', + onClick: () => handleApprove(submission), + }, + { + label: 'Tolak', + icon: , + iconClassName: 'text-destructive', + show: submission.status === 'submitted', + onClick: () => handleRejectClick(submission), + }, + ]} + /> + ); }, - cell: ({ row }) => ( - , - onClick: () => handleEdit(row.original), - }, - { - label: 'Hapus', - icon: ( - - ), - onClick: () => handleDeleteClick(row.original), - }, - ]} - /> - ), }, ]; } diff --git a/resources/js/pages/admin/manage/course-registrations/index.tsx b/resources/js/pages/admin/manage/course-registrations/index.tsx index 9f9361f..c7bb503 100644 --- a/resources/js/pages/admin/manage/course-registrations/index.tsx +++ b/resources/js/pages/admin/manage/course-registrations/index.tsx @@ -3,13 +3,21 @@ import { Plus } from 'lucide-react'; import { useState } from 'react'; import type { PaginationState } from '@/components/data-table'; import { DataTable } from '@/components/data-table'; -import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; 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, @@ -18,18 +26,19 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; import { useServerTable } from '@/hooks/use-server-table'; import { index as courseRegistrationIndex, - destroy, + approve, + reject, store, - update, } from '@/routes/admin/manage/course-registrations'; import { formatAcademicTermLabel } from '@/types/academic-term'; import type { - CourseRegistration, CourseRegistrationCourseClass, CourseRegistrationStudent, + CourseRegistrationSubmission, } from '@/types/course-registration'; import { RegistrationStatuses, @@ -37,16 +46,16 @@ import { } from '@/types/course-registration'; import { createCourseRegistrationColumns } from './columns'; -type AcademicTermOption = { id: number; name: string; semester: string }; -type LecturerOption = { +type AcademicTermOption = { id: number; - lecturer_number: string; - user: { profile: { full_name: string } | null } | null; + name: string; + semester: string; + is_active: boolean; }; type Props = { registrations: { - data: CourseRegistration[]; + data: CourseRegistrationSubmission[]; current_page: number; last_page: number; per_page: number; @@ -55,7 +64,6 @@ type Props = { students: CourseRegistrationStudent[]; academicTerms: AcademicTermOption[]; courseClasses: CourseRegistrationCourseClass[]; - lecturers: LecturerOption[]; highlight?: number; filters: { status?: string; @@ -73,22 +81,17 @@ function courseClassLabel(courseClass: CourseRegistrationCourseClass): string { return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}`; } -function lecturerLabel(lecturer: LecturerOption): string { - return `${lecturer.user?.profile?.full_name ?? 'N/A'} - ${lecturer.lecturer_number}`; -} - export default function CourseRegistrationIndex({ registrations, students, academicTerms, courseClasses, - lecturers, highlight, filters, }: Props) { const [createOpen, setCreateOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [deleting, setDeleting] = useState(null); + const [rejecting, setRejecting] = + useState(null); const filterFields: FilterField[] = [ { @@ -128,19 +131,13 @@ export default function CourseRegistrationIndex({ filters, }); - function handleDelete() { - if (!deleting) { - return; - } - - router.delete(destroy(deleting.id), { - onSuccess: () => setDeleting(null), - }); + function handleApprove(submission: CourseRegistrationSubmission) { + router.patch(approve(submission.id)); } const columns = createCourseRegistrationColumns({ - handleEdit: (registration) => setEditing(registration), - handleDeleteClick: (registration) => setDeleting(registration), + handleApprove, + handleRejectClick: (submission) => setRejecting(submission), }); return ( @@ -176,22 +173,6 @@ export default function CourseRegistrationIndex({ students={students} academicTerms={academicTerms} courseClasses={courseClasses} - lecturers={lecturers} - /> - - { - if (!open) { - setEditing(null); - } - }} - editing={editing} - students={students} - academicTerms={academicTerms} - courseClasses={courseClasses} - lecturers={lecturers} /> - { if (!open) { - setDeleting(null); + setRejecting(null); } }} - title="Hapus Registrasi" - description={(registration) => - `Apakah Anda yakin ingin menghapus registrasi KRS untuk "${registration.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.` - } - onConfirm={handleDelete} + 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. +

+
+ +