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
- {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 && (
-
- setCreateOpen(true)}
- >
-
- Tambah
-
-
- )
- }
- />
-
-
+
}
/>
-
- {
- 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.
-
-
-
- Alasan Penolakan{' '}
- *
-
-
-
-
-
- )}
-
- );
-}
-
-function RegistrationFields({
- errors,
- students,
- academicTerms,
- courseClasses,
-}: {
- errors: Record;
- students: CourseRegistrationStudent[];
- academicTerms: AcademicTermOption[];
- courseClasses: CourseRegistrationCourseClass[];
-}) {
- const activeTermId = academicTerms.find((term) => term.is_active)?.id;
-
- const [student, setStudent] = useState(
- null,
- );
- const [academicTermId, setAcademicTermId] = useState(() =>
- activeTermId ? String(activeTermId) : '',
- );
-
- const availableCourseClasses = courseClasses.filter(
- (courseClass) =>
- student &&
- academicTermId &&
- courseClass.academic_term_id === Number(academicTermId) &&
- courseClass.course?.semester_number === student.current_semester &&
- courseClass.course?.department_id === student.department?.id,
- );
-
- const selectionKey = `${student?.id ?? ''}-${academicTermId}`;
- const [selected, setSelected] = useState([]);
- const [selectedForKey, setSelectedForKey] = useState(selectionKey);
-
- if (selectionKey !== selectedForKey) {
- setSelectedForKey(selectionKey);
- setSelected([]);
- }
-
- function toggle(courseClassId: number, checked: boolean) {
- setSelected((prev) =>
- checked
- ? [...prev, courseClassId]
- : prev.filter((id) => id !== courseClassId),
- );
- }
-
- return (
- <>
-
-
- Mahasiswa *
-
-
- a.id === b.id}
- >
-
-
-
- Mahasiswa tidak ditemukan.
-
-
- {(option: CourseRegistrationStudent) => (
-
- {studentLabel(option)}
-
- )}
-
-
-
-
-
-
-
- Periode Akademik *
-
-
-
-
-
-
-
- {academicTerms.map((term) => (
-
- {formatAcademicTermLabel(term)}
-
- ))}
-
-
-
-
-
-
- Kelas Mata Kuliah{' '}
- *
-
- {!student || !academicTermId ? (
-
- Pilih mahasiswa dan periode terlebih dahulu.
-
- ) : availableCourseClasses.length === 0 ? (
-
- Tidak ada kelas mata kuliah yang tersedia untuk semester
- mahasiswa ini.
-
- ) : (
-
- {availableCourseClasses.map((courseClass) => (
-
-
- toggle(courseClass.id, checked === true)
- }
- />
-
- {courseClassLabel(courseClass)}
-
-
- ))}
-
- )}
- {selected.map((courseClassId) => (
-
- ))}
-
-
- >
- );
-}
-
-function CreateForm({
- open,
- onOpenChange,
- students,
- academicTerms,
- courseClasses,
-}: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- students: CourseRegistrationStudent[];
- academicTerms: AcademicTermOption[];
- courseClasses: CourseRegistrationCourseClass[];
-}) {
- return (
- onOpenChange(false)}
- >
- {({ errors }) => (
-
-
-
- )}
-
- );
-}
diff --git a/resources/js/pages/admin/manage/course-registrations/show.tsx b/resources/js/pages/admin/manage/course-registrations/show.tsx
index 556f786..0f94274 100644
--- a/resources/js/pages/admin/manage/course-registrations/show.tsx
+++ b/resources/js/pages/admin/manage/course-registrations/show.tsx
@@ -1,150 +1,150 @@
-import { Head, Link } from '@inertiajs/react';
+import { Form, Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
-import { ArrowLeft } from 'lucide-react';
+import { ArrowLeft, Check, Send, X } from 'lucide-react';
+import { useState } from 'react';
+import { FormDialog } from '@/components/form-dialog';
+import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
+import { SignaturePad } from '@/components/signature-pad';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { index as courseRegistrationIndex } from '@/routes/admin/manage/course-registrations';
-import { formatAcademicTermLabel } from '@/types/academic-term';
-import type { CourseRegistrationSubmission } from '@/types/course-registration';
-import { RegistrationStatusLabels } from '@/types/course-registration';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Label } from '@/components/ui/label';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ advisor_signature,
+ approve,
+ kaprodi_signature,
+ reject,
+ save,
+} from '@/routes/admin/manage/course-registrations';
+import type {
+ CourseRegistrationSubmission,
+ CourseRegistrationSubmissionEntry,
+ CourseRegistrationSubmissionLog,
+} from '@/types/course-registration';
+import {
+ RegistrationStatusLabels,
+ semesterKey,
+} from '@/types/course-registration';
-type Props = {
- submission: CourseRegistrationSubmission;
+const RegistrationStatusVariants: Record<
+ CourseRegistrationSubmission['status'],
+ 'default' | 'secondary' | 'destructive' | 'outline'
+> = {
+ submitted: 'outline',
+ approved: 'default',
+ rejected: 'destructive',
};
-export default function CourseRegistrationShow({ submission }: Props) {
- const student = submission.student;
+function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ : {value}
+
+ );
+}
+
+function SignatureBox({
+ signatureUrl,
+ alt,
+ error,
+ editable,
+}: {
+ signatureUrl: string | null;
+ alt: string;
+ error?: string;
+ editable: boolean;
+}) {
+ if (!editable) {
+ if (!signatureUrl) {
+ return
;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
+
+function ReviewerSignatureField({
+ signatureUrl,
+ alt,
+ canSign,
+ submitUrl,
+}: {
+ signatureUrl: string | null;
+ alt: string;
+ canSign: boolean;
+ submitUrl: string;
+}) {
+ const [file, setFile] = useState(null);
+ const [processing, setProcessing] = useState(false);
+
+ if (signatureUrl) {
+ return (
+
+
+
+ );
+ }
+
+ if (!canSign) {
+ return
;
+ }
+
+ function handleSave() {
+ if (!file) {
+ return;
+ }
+
+ setProcessing(true);
+ router.patch(
+ submitUrl,
+ { signature: file },
+ {
+ preserveScroll: true,
+ onFinish: () => setProcessing(false),
+ },
+ );
+ }
return (
- <>
-
-
-
-
-
-
- Kembali
-
-
- }
- />
-
-
-
-
-
- {student?.user?.profile?.full_name ?? 'N/A'}
-
-
- {student?.student_number} ·{' '}
- {student?.department?.name ?? '-'} ·
- Semester {student?.current_semester ?? '-'}
-
-
- Periode:{' '}
- {submission.academic_term
- ? formatAcademicTermLabel(
- submission.academic_term,
- )
- : '-'}
-
-
-
- {RegistrationStatusLabels[submission.status]}
-
-
-
-
-
-
-
- Tanda Tangan
-
-
-
- {submission.signature_url ? (
-
-
-
- ) : (
-
- Tidak ada tanda tangan.
-
- )}
-
- Ditandatangani pada{' '}
- {format(
- new Date(submission.signed_at),
- 'd MMM yyyy, HH:mm',
- )}
-
-
-
-
-
-
-
- Mata Kuliah (
- {submission.course_registrations.length})
-
-
-
- {submission.course_registrations.map((registration) => (
-
-
- {registration.course_class?.course?.name ??
- 'N/A'}
-
-
- {registration.course_class?.course?.code ??
- '-'}
-
-
- ))}
-
-
-
-
-
- Riwayat
-
-
- {submission.logs.map((log, index) => (
- l.status === 'submitted')
- }
- />
- ))}
-
-
-
- >
+
+
+
+ {processing ? 'Menyimpan...' : 'Simpan TTD'}
+
+
);
}
@@ -152,7 +152,7 @@ function LogBubble({
log,
isResubmission,
}: {
- log: CourseRegistrationSubmission['logs'][number];
+ log: CourseRegistrationSubmissionLog;
isResubmission: boolean;
}) {
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
@@ -182,3 +182,510 @@ function LogBubble({
);
}
+
+function RejectDialog({
+ open,
+ onOpenChange,
+ submissionId,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ submissionId: number;
+}) {
+ return (
+ onOpenChange(false)}
+ >
+ {({ errors }) => (
+
+
+ Alasan Penolakan{' '}
+ *
+
+
+
+
+ )}
+
+ );
+}
+
+function SemesterCard({
+ semester,
+ submission,
+ studentId,
+ isSemesterOpen,
+ backHref,
+ canReview,
+ canSignAsKaprodi,
+ canSignAsAdvisor,
+}: {
+ semester: number | null;
+ submission: CourseRegistrationSubmission;
+ studentId: number;
+ isSemesterOpen: boolean;
+ backHref: string | null;
+ canReview: boolean;
+ canSignAsKaprodi: boolean;
+ canSignAsAdvisor: boolean;
+}) {
+ const [rejecting, setRejecting] = useState(false);
+
+ const student = submission.student;
+ const department = student?.department ?? null;
+ const kaprodi = department?.current_leader?.lecturer ?? null;
+ const advisor = student?.academic_advisor ?? null;
+
+ const isViewOnly = backHref !== null;
+ const notYetSubmitted =
+ submission.status === 'submitted' && !submission.has_registrations;
+ const isLocked =
+ submission.status === 'approved' ||
+ (submission.status === 'submitted' && submission.has_registrations);
+ const canDecide =
+ canReview && submission.status === 'submitted' && !notYetSubmitted;
+ const canReviewersSign =
+ submission.status === 'submitted' && !notYetSubmitted;
+ const missingKaprodiSignature = !submission.kaprodi_signature_url;
+ const missingOwnSignature =
+ (canSignAsKaprodi && !submission.kaprodi_signature_url) ||
+ (canSignAsAdvisor && !submission.advisor_signature_url);
+ const cannotApprove = missingKaprodiSignature || missingOwnSignature;
+
+ const totalCredits = submission.course_registrations.reduce(
+ (sum, registration) =>
+ submission.has_registrations
+ ? registration.is_registered
+ ? sum + (registration.course_class?.course?.credits ?? 0)
+ : sum
+ : isSemesterOpen
+ ? sum + (registration.course_class?.course?.credits ?? 0)
+ : sum,
+ 0,
+ );
+
+ function handleApprove() {
+ router.patch(approve.url(submission.id), {}, { preserveScroll: true });
+ }
+
+ return (
+
+ );
+}
+
+type Props = {
+ submissions: Record;
+ semesters: (number | null)[];
+ openSemesters: number[];
+ backHref: string | null;
+ canReview: boolean;
+ canSignAsKaprodi: boolean;
+ canSignAsAdvisor: boolean;
+};
+
+export default function CourseRegistrationDetail({
+ submissions,
+ semesters,
+ openSemesters,
+ backHref,
+ canReview,
+ canSignAsKaprodi,
+ canSignAsAdvisor,
+}: Props) {
+ const [activeSemester, setActiveSemester] = useState(() =>
+ semesterKey(semesters[0] ?? null),
+ );
+ const currentKey = semesters.some(
+ (semester) => semesterKey(semester) === activeSemester,
+ )
+ ? activeSemester
+ : semesterKey(semesters[0] ?? null);
+
+ const currentSemester =
+ semesters.find((semester) => semesterKey(semester) === currentKey) ??
+ null;
+ const currentSubmission = submissions[currentKey];
+ const studentId = Object.values(submissions)[0]?.student?.id ?? 0;
+
+ return (
+ <>
+
+
+
+
+
+
+ Kembali
+
+
+ )
+ }
+ />
+
+ {semesters.length > 0 && (
+
+
+ {semesters.map((semester) => (
+
+ {semester !== null
+ ? `Semester ${semester}`
+ : 'Lainnya'}
+ {!openSemesters.includes(semester ?? -1) &&
+ ' (Ditutup)'}
+
+ ))}
+
+
+ )}
+
+ {currentSubmission && (
+
+ )}
+
+ >
+ );
+}
diff --git a/resources/js/pages/admin/master/academic-terms/columns.tsx b/resources/js/pages/admin/master/academic-terms/columns.tsx
index 62978be..7eff808 100644
--- a/resources/js/pages/admin/master/academic-terms/columns.tsx
+++ b/resources/js/pages/admin/master/academic-terms/columns.tsx
@@ -2,6 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { ActiveStatusSwitch } from '@/components/active-status-switch';
import { RowActions } from '@/components/row-actions';
+import { Badge } from '@/components/ui/badge';
import { SemesterLabels } from '@/types/academic-term';
import type { AcademicTerm } from '@/types/academic-term';
import type { Semester } from '@/types/academic-term';
@@ -66,6 +67,33 @@ export function createAcademicTermColumns(
),
},
+ {
+ id: 'open_semesters',
+ header: () => Semester Dibuka ,
+ cell: ({ row }) => {
+ const openSemesters = row.original.open_semesters ?? [];
+
+ if (openSemesters.length === 0) {
+ return (
+
+ Semua Tertutup
+
+ );
+ }
+
+ return (
+
+ {[...openSemesters]
+ .sort((a, b) => a - b)
+ .map((semester) => (
+
+ {semester}
+
+ ))}
+
+ );
+ },
+ },
{
accessorKey: 'is_active',
header: () => Status ,
diff --git a/resources/js/pages/student/course-registrations/columns.tsx b/resources/js/pages/student/course-registrations/columns.tsx
deleted file mode 100644
index 930c100..0000000
--- a/resources/js/pages/student/course-registrations/columns.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import type { ColumnDef } from '@tanstack/react-table';
-import { format } from 'date-fns';
-import { Eye } from 'lucide-react';
-import { RowActions } from '@/components/row-actions';
-import { Badge } from '@/components/ui/badge';
-import { show } from '@/routes/student/course-registrations';
-import { formatAcademicTermLabel } from '@/types/academic-term';
-import type { RegistrationStatus } from '@/types/course-registration';
-import { RegistrationStatusLabels } from '@/types/course-registration';
-
-export type SubmissionSummary = {
- id: number;
- status: RegistrationStatus;
- signed_at: string;
- academic_term: { id: number; name: string; semester: string } | null;
- course_registrations_count: number;
-};
-
-export const submissionColumns: ColumnDef[] = [
- {
- id: 'academic_term',
- header: () => Periode Akademik ,
- 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_count,
- },
- {
- 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: 'signed_at',
- header: () => Tanggal ,
- cell: ({ row }) =>
- format(new Date(row.original.signed_at), 'd MMM yyyy, HH:mm'),
- },
- {
- id: 'actions',
- header: () => Aksi ,
- meta: {
- className: 'w-[80px] text-center',
- headerClassName: 'w-[80px] text-center',
- },
- cell: ({ row }) => (
- ,
- href: show.url(row.original.id),
- },
- ]}
- />
- ),
- },
-];
diff --git a/resources/js/pages/student/course-registrations/create.tsx b/resources/js/pages/student/course-registrations/create.tsx
deleted file mode 100644
index d0be9f2..0000000
--- a/resources/js/pages/student/course-registrations/create.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-import { Form, Head, Link } from '@inertiajs/react';
-import { ArrowLeft, XCircle } from 'lucide-react';
-import { useState } from 'react';
-import InputError from '@/components/input-error';
-import { PageHeader } from '@/components/page-header';
-import { SignaturePad } from '@/components/signature-pad';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import { Checkbox } from '@/components/ui/checkbox';
-import { index, store } from '@/routes/student/course-registrations';
-import type { AcademicTerm } from '@/types/academic-term';
-import { formatAcademicTermLabel } from '@/types/academic-term';
-
-type Lecturer = {
- user: { profile: { full_name: string } | null } | null;
-};
-
-type Course = {
- id: number;
- code: string;
- name: string;
- credits: number;
-};
-
-type CourseClass = {
- id: number;
- lecturer: Lecturer | null;
-};
-
-type Student = {
- student_number: string;
- current_semester: number;
- department: { name: string } | null;
- user: { profile: { full_name: string } | null } | null;
- academic_advisor: {
- user: { profile: { full_name: string } | null } | null;
- } | null;
-};
-
-type AvailableCourse = {
- course: Course;
- course_class: CourseClass | null;
-};
-
-type Registration = {
- id: number;
- course_class: { id: number };
-};
-
-type Submission = {
- status: 'submitted' | 'approved' | 'rejected';
- rejection_reason: string | null;
- course_registrations: Registration[];
-};
-
-type Props = {
- student: Student;
- academicTerm: AcademicTerm;
- submission: Submission | null;
- availableCourses: AvailableCourse[];
-};
-
-export default function CourseRegistrationCreate({
- student,
- academicTerm,
- submission,
- availableCourses,
-}: Props) {
- const [selected, setSelected] = useState(() => {
- if (submission && submission.course_registrations.length > 0) {
- return submission.course_registrations.map(
- (registration) => registration.course_class.id,
- );
- }
-
- return availableCourses
- .filter((item) => item.course_class)
- .map((item) => item.course_class!.id);
- });
-
- function toggle(courseClassId: number, checked: boolean) {
- setSelected((prev) =>
- checked
- ? [...prev, courseClassId]
- : prev.filter((id) => id !== courseClassId),
- );
- }
-
- const totalCredits = availableCourses
- .filter(
- (item) =>
- item.course_class && selected.includes(item.course_class.id),
- )
- .reduce((sum, item) => sum + item.course.credits, 0);
-
- return (
- <>
-
-
-
-
-
-
- Kembali
-
-
- }
- />
-
-
-
-
- {student.user?.profile?.full_name ?? '-'}
-
-
- {student.student_number} ·{' '}
- {student.department?.name ?? '-'} · Semester{' '}
- {student.current_semester}
-
-
-
-
- Periode Akademik:{' '}
- {formatAcademicTermLabel(academicTerm)}
-
-
- Dosen Pembimbing Akademik:{' '}
- {student.academic_advisor?.user?.profile
- ?.full_name ?? '-'}
-
-
-
-
- {submission?.status === 'rejected' && (
-
-
-
-
- KRS Anda ditolak
-
-
- {submission.rejection_reason ??
- 'Tidak ada alasan yang diberikan.'}
-
-
- Silakan perbaiki pilihan mata kuliah Anda dan
- ajukan kembali di bawah ini.
-
-
-
- )}
-
-
-
- >
- );
-}
diff --git a/resources/js/pages/student/course-registrations/index.tsx b/resources/js/pages/student/course-registrations/index.tsx
deleted file mode 100644
index a30d1c4..0000000
--- a/resources/js/pages/student/course-registrations/index.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import { Head, Link } from '@inertiajs/react';
-import { XCircle } from 'lucide-react';
-import { DataTable } from '@/components/data-table';
-import { PageHeader } from '@/components/page-header';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
-import { formatRupiah } from '@/lib/currency';
-import { create } from '@/routes/student/course-registrations';
-import type { AcademicTerm } from '@/types/academic-term';
-import type { RegistrationStatus } from '@/types/course-registration';
-import type { SubmissionSummary } from './columns';
-import { submissionColumns } from './columns';
-
-type PaymentStatus = {
- has_invoice: boolean;
- is_paid: boolean;
- amount_due: number | null;
- paid_total: number | null;
-};
-
-type ActiveSubmission = {
- status: RegistrationStatus;
- rejection_reason: string | null;
-};
-
-type Props = {
- academicTerm: AcademicTerm | null;
- payment: PaymentStatus | null;
- canSubmit: boolean;
- activeSubmission: ActiveSubmission | null;
- submissions: SubmissionSummary[];
-};
-
-export default function CourseRegistrationIndex({
- academicTerm,
- payment,
- canSubmit,
- activeSubmission,
- submissions,
-}: Props) {
- return (
- <>
-
-
-
-
- Periode aktif: {academicTerm.name} (
- {academicTerm.semester === 'odd'
- ? 'Ganjil'
- : 'Genap'}
- )
-
- )
- }
- />
-
- {!academicTerm && (
-
-
- Belum ada periode akademik yang aktif. Silakan
- hubungi admin.
-
-
- )}
-
- {academicTerm && payment && !payment.is_paid && (
-
-
-
- Anda belum bisa mengisi KRS periode ini.
-
-
- {payment.has_invoice
- ? `Tagihan periode ini belum lunas (terbayar ${formatRupiah(payment.paid_total ?? 0)} dari ${formatRupiah(payment.amount_due ?? 0)}).`
- : 'Tagihan untuk periode ini belum diterbitkan. Silakan hubungi staf keuangan.'}
-
-
-
- )}
-
- {academicTerm && payment?.is_paid && canSubmit && (
-
-
-
- {activeSubmission?.status === 'rejected' ? (
- <>
-
-
- KRS Anda untuk periode ini ditolak
-
-
- {activeSubmission.rejection_reason ??
- 'Tidak ada alasan yang diberikan.'}{' '}
- Silakan perbaiki dan ajukan kembali.
-
- >
- ) : (
-
- Anda belum mengisi KRS untuk periode
- aktif ini.
-
- )}
-
-
-
- {activeSubmission?.status === 'rejected'
- ? 'Ajukan Ulang KRS'
- : 'Isi KRS'}
-
-
-
-
- )}
-
-
-
- >
- );
-}
diff --git a/resources/js/pages/student/course-registrations/show.tsx b/resources/js/pages/student/course-registrations/show.tsx
deleted file mode 100644
index 7b294f0..0000000
--- a/resources/js/pages/student/course-registrations/show.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import { Head, Link } from '@inertiajs/react';
-import { format } from 'date-fns';
-import { ArrowLeft } from 'lucide-react';
-import { PageHeader } from '@/components/page-header';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { index } from '@/routes/student/course-registrations';
-import { formatAcademicTermLabel } from '@/types/academic-term';
-import type { CourseRegistrationSubmission } from '@/types/course-registration';
-import { RegistrationStatusLabels } from '@/types/course-registration';
-
-type Props = {
- submission: CourseRegistrationSubmission;
-};
-
-export default function CourseRegistrationShow({ submission }: Props) {
- return (
- <>
-
-
-
-
-
-
- Kembali
-
-
- }
- />
-
-
-
-
-
- {submission.academic_term
- ? formatAcademicTermLabel(
- submission.academic_term,
- )
- : '-'}
-
-
- Ditandatangani pada{' '}
- {format(
- new Date(submission.signed_at),
- 'd MMM yyyy, HH:mm',
- )}
-
-
-
- {RegistrationStatusLabels[submission.status]}
-
-
-
-
-
-
-
- Tanda Tangan
-
-
-
- {submission.signature_url ? (
-
-
-
- ) : (
-
- Tidak ada tanda tangan.
-
- )}
-
-
-
-
-
-
- Mata Kuliah (
- {submission.course_registrations.length})
-
-
-
- {submission.course_registrations.map((registration) => (
-
-
- {registration.course_class?.course?.code ??
- '-'}{' '}
- -{' '}
- {registration.course_class?.course?.name ??
- 'N/A'}
-
-
- {registration.course_class?.course
- ?.credits ?? '-'}{' '}
- SKS ·{' '}
- {registration.course_class?.lecturer?.user
- ?.profile?.full_name ??
- 'Dosen belum ditentukan'}
-
-
- ))}
-
-
-
-
-
- Riwayat
-
-
- {submission.logs.map((log, index) => (
- l.status === 'submitted')
- }
- />
- ))}
-
-
-
- >
- );
-}
-
-function LogBubble({
- log,
- isResubmission,
-}: {
- log: CourseRegistrationSubmission['logs'][number];
- isResubmission: boolean;
-}) {
- const actorName = log.actor?.profile?.full_name ?? 'Sistem';
-
- const message =
- log.status === 'approved'
- ? `${actorName} menyetujui KRS ini.`
- : log.status === 'rejected'
- ? `${actorName} menolak KRS ini dengan alasan: "${log.reason}"`
- : isResubmission
- ? `${actorName} mengajukan ulang KRS.`
- : `${actorName} mengajukan KRS.`;
-
- const colorClasses =
- log.status === 'approved'
- ? 'border-primary/30 bg-primary/10'
- : log.status === 'rejected'
- ? 'border-destructive/30 bg-destructive/10'
- : 'border-border bg-muted';
-
- return (
-
-
{message}
-
- {format(new Date(log.created_at), 'd MMM yyyy, HH:mm')}
-
-
- );
-}
diff --git a/resources/js/types/course-registration.ts b/resources/js/types/course-registration.ts
index 94e1f53..896954a 100644
--- a/resources/js/types/course-registration.ts
+++ b/resources/js/types/course-registration.ts
@@ -15,63 +15,78 @@ export const RegistrationStatusLabels: Record = {
export type CourseRegistrationStudent = {
id: number;
student_number: string;
- current_semester: number;
+ user: { profile: { full_name: string } | null } | null;
department: { id: number; name: string } | null;
+};
+
+export type CourseRegistrationRow = {
+ student_id: number;
+ student: CourseRegistrationStudent | null;
+};
+
+export type DepartmentSummary = {
+ id: number;
+ name: string;
+};
+
+export type CourseRegistrationSubmissionLecturer = {
+ lecturer_number: string;
user: { profile: { full_name: string } | null } | null;
};
-export type CourseRegistrationCourseClass = {
+export type CourseRegistrationSubmissionStudent = {
id: number;
- academic_term_id: number;
- course: {
+ student_number: string;
+ current_semester: number;
+ department: {
id: number;
- code: string;
name: string;
- semester_number: number | null;
- department_id: number;
- credits?: number;
- } | null;
- lecturer?: {
- user: { profile: { full_name: string } | null } | null;
+ current_leader: {
+ lecturer: CourseRegistrationSubmissionLecturer | null;
+ } | null;
} | null;
+ user: { profile: { full_name: string } | null } | null;
+ academic_advisor: CourseRegistrationSubmissionLecturer | null;
};
-export type CourseRegistration = {
+export type CourseRegistrationSubmissionEntry = {
id: number;
- student_id: number;
- academic_term_id: number;
- course_class_id: number;
- course_class: CourseRegistrationCourseClass | null;
- created_at: string;
- updated_at: string;
-};
-
-export type CourseRegistrationActor = {
- id: number;
- profile: { full_name: string } | null;
+ is_registered: boolean;
+ course_class: {
+ id: number;
+ course: {
+ id: number;
+ code: string;
+ name: string;
+ credits: number;
+ semester_number: number | null;
+ } | null;
+ } | null;
};
export type CourseRegistrationSubmissionLog = {
id: number;
status: RegistrationStatus;
reason: string | null;
- actor: CourseRegistrationActor | null;
+ actor: { profile: { full_name: string } | null } | null;
created_at: string;
};
export type CourseRegistrationSubmission = {
id: number;
- student_id: number;
- student: CourseRegistrationStudent | null;
- academic_term_id: number;
- academic_term: { id: number; name: string; semester: string } | null;
status: RegistrationStatus;
+ rejection_reason: string | null;
signed_at: string;
signature_url: string | null;
- rejection_reason: string | null;
- reviewed_by: number | null;
- reviewer: CourseRegistrationActor | null;
- reviewed_at: string | null;
- course_registrations: CourseRegistration[];
+ kaprodi_signature_url: string | null;
+ advisor_signature_url: string | null;
+ has_registrations: boolean;
+ student: CourseRegistrationSubmissionStudent | null;
+ academic_term: { id: number; name: string; semester: string } | null;
+ course_registrations: CourseRegistrationSubmissionEntry[];
logs: CourseRegistrationSubmissionLog[];
};
+
+export function semesterKey(semester: number | null): string {
+ return semester === null ? 'lainnya' : String(semester);
+}
diff --git a/routes/admin.php b/routes/admin.php
index 622d65e..5639982 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -98,15 +98,14 @@
});
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
- Route::resource('course-registrations', CourseRegistrationController::class)
- ->only(['index', 'store'])
- ->middlewareFor(['index'], 'permission:view-course-registrations')
- ->middlewareFor(['store'], 'permission:create-course-registrations');
-
- Route::prefix('course-registrations/{submission}')->name('course-registrations.')->group(function () {
- Route::get('/', [CourseRegistrationController::class, 'show'])->name('show')->middleware('permission:view-course-registrations');
- Route::patch('approve', [CourseRegistrationController::class, 'approve'])->name('approve')->middleware('permission:approve-course-registrations');
- Route::patch('reject', [CourseRegistrationController::class, 'reject'])->name('reject')->middleware('permission:reject-course-registrations');
+ Route::prefix('course-registrations')->name('course-registrations.')->group(function () {
+ Route::get('/', [CourseRegistrationController::class, 'index'])->name('index')->middleware('permission:view-course-registrations');
+ Route::patch('submissions/{submission}/approve', [CourseRegistrationController::class, 'approve'])->name('approve')->middleware('permission:approve-course-registrations');
+ Route::patch('submissions/{submission}/reject', [CourseRegistrationController::class, 'reject'])->name('reject')->middleware('permission:reject-course-registrations');
+ Route::get('{student}', [CourseRegistrationController::class, 'show'])->name('show')->middleware('permission:view-course-registrations');
+ Route::put('{student}/{semester}', [CourseRegistrationController::class, 'save'])->name('save')->middleware('permission:view-course-registrations');
+ Route::patch('{student}/{semester}/kaprodi-signature', [CourseRegistrationController::class, 'signAsKaprodi'])->name('kaprodi_signature')->middleware('permission:view-course-registrations');
+ Route::patch('{student}/{semester}/advisor-signature', [CourseRegistrationController::class, 'signAsAdvisor'])->name('advisor_signature')->middleware('permission:approve-course-registrations');
});
Route::resource('course-classes', CourseClassController::class)
@@ -130,15 +129,6 @@
});
});
- Route::middleware('role:mahasiswa')->group(function () {
- Route::prefix('course-registrations')->name('student.course-registrations.')->group(function () {
- Route::get('/', [CourseRegistrationController::class, 'mine'])->name('index');
- Route::get('create', [CourseRegistrationController::class, 'create'])->name('create');
- Route::post('/', [CourseRegistrationController::class, 'store'])->name('store');
- Route::get('{submission}', [CourseRegistrationController::class, 'show'])->name('show');
- });
- });
-
Route::prefix('admin/finances')->name('admin.finances.')->group(function () {
Route::resource('tuition-invoices', TuitionInvoiceController::class)
->except(['create', 'edit', 'show'])