feat: add open semesters badge to academic terms and remove course registration pages

- Added a new column to display open semesters in the academic terms table with badges.
- Removed course registration related pages and components including index, create, and show.
- Updated course registration types to reflect changes in the data structure.
- Refactored admin routes for course registrations to streamline submission handling.
This commit is contained in:
Yoga Pangestu 2026-09-01 21:39:46 +07:00
parent 6621757713
commit 692b84cc1b
24 changed files with 1280 additions and 1716 deletions

View File

@ -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;
}
}

View File

@ -1,75 +0,0 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Services\Admin\Manage\CourseRegistrationService;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->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',
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
class SaveCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
$student = $this->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'],
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
class SignCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'signature' => ['required', 'file', 'image', 'max:2048'],
];
}
}

View File

@ -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;
}
}

View File

@ -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<int, int>
*/
public function ledDepartmentIds(): array
{
return $this->lecturer
? $this->lecturer->leaderships()->whereNull('ended_at')->pluck('department_id')->all()
: [];
}
}

View File

@ -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<int, Department>
*/
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<int, int>} $data
* @param array<int, int>|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<string, mixed>
*/
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<int, CourseClass>
*/
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<int, int> $openSemesters
* @return array<int, int>
*/
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<int, int> $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<int, array{course: Course, course_class: ?CourseClass}>
*/
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<int, CourseRegistrationSubmission>
*/
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<int, int>
*/
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<int, int> $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;
});
}
}

View File

@ -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',
];

View File

@ -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');
});
}

View File

@ -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,

View File

@ -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' => [

View File

@ -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<typeof Sidebar>) {
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 (
<Sidebar collapsible="icon" {...props}>

View File

@ -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 <form> (e.g. via router). */
onCapture?: (file: File | null) => void;
};
export function SignaturePad({ name, error }: SignaturePadProps) {
export function SignaturePad({
name,
error,
compact,
onCapture,
}: SignaturePadProps) {
const padRef = useRef<SignatureCanvasImport>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// The canvas element's drawing-surface resolution (width/height
// attributes) does not automatically follow its CSS-rendered size, so
// without this, pointer coordinates drift from where ink is drawn —
// worse on HiDPI screens. Size the backing buffer to match on mount and
// whenever the layout might change.
useEffect(() => {
function resizeCanvas() {
const canvas = padRef.current?.getCanvas();
if (!canvas) {
return;
}
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext('2d')?.scale(ratio, ratio);
padRef.current?.clear();
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => window.removeEventListener('resize', resizeCanvas);
}, []);
function syncFileInput() {
const pad = padRef.current;
const input = fileInputRef.current;
@ -43,6 +80,7 @@ export function SignaturePad({ name, error }: SignaturePadProps) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
onCapture?.(file);
}, 'image/png');
}
@ -52,6 +90,41 @@ export function SignaturePad({ name, error }: SignaturePadProps) {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onCapture?.(null);
}
if (compact) {
return (
<div className="grid gap-1">
<div className="relative h-28 w-48 overflow-hidden rounded border bg-white">
<SignatureCanvas
ref={padRef}
penColor="#0f172a"
canvasProps={{
className:
'h-28 w-48 cursor-crosshair touch-none',
}}
onEnd={syncFileInput}
/>
<button
type="button"
onClick={handleClear}
aria-label="Hapus tanda tangan"
className="absolute top-0.5 right-0.5 rounded bg-white/80 p-0.5 text-slate-500 hover:text-slate-900"
>
<Eraser className="h-3 w-3" />
</button>
</div>
<input
ref={fileInputRef}
type="file"
name={name}
className="hidden"
/>
<InputError message={error} />
</div>
);
}
return (

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"

View File

@ -1,25 +1,12 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Check, Eye, X } from 'lucide-react';
import { Eye } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { show } from '@/routes/admin/manage/course-registrations';
import type { CourseRegistrationSubmission } from '@/types/course-registration';
import { RegistrationStatusLabels } from '@/types/course-registration';
import type { CourseRegistrationRow } from '@/types/course-registration';
export type { CourseRegistrationSubmission } from '@/types/course-registration';
type CreateColumnsParams = {
handleApprove: (submission: CourseRegistrationSubmission) => void;
handleRejectClick: (submission: CourseRegistrationSubmission) => void;
canApprove: boolean;
canReject: boolean;
};
export function createCourseRegistrationColumns(
params: CreateColumnsParams,
): ColumnDef<CourseRegistrationSubmission>[] {
const { handleApprove, handleRejectClick, canApprove, canReject } = params;
export type { CourseRegistrationRow } from '@/types/course-registration';
export function createCourseRegistrationColumns(): ColumnDef<CourseRegistrationRow>[] {
return [
{
id: 'student',
@ -33,98 +20,35 @@ export function createCourseRegistrationColumns(
{student?.user?.profile?.full_name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{student?.student_number} &middot;{' '}
{student?.department?.name ?? '-'}
{student?.student_number}
</p>
</div>
);
},
},
{
id: 'semester',
header: () => <span className="block text-center">Semester</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => row.original.student?.current_semester ?? '-',
},
{
id: 'count',
header: () => <span className="block text-center">Jumlah</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => row.original.course_registrations.length,
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'w-[140px] text-center',
headerClassName: 'w-[140px] text-center',
},
cell: ({ row }) => {
const status = row.original.status;
return (
<div className="flex justify-center">
<Badge
variant={
status === 'approved'
? 'default'
: status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[status]}
</Badge>
</div>
);
},
id: 'department',
header: () => <span>Jurusan</span>,
cell: ({ row }) => row.original.student?.department?.name ?? '-',
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[130px] text-center',
headerClassName: 'w-[130px] text-center',
},
cell: ({ row }) => {
const submission = row.original;
return (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(submission.id),
},
{
label: 'Setujui',
icon: <Check className="h-4 w-4" />,
iconClassName: 'text-primary',
show:
submission.status === 'submitted' &&
canApprove,
onClick: () => handleApprove(submission),
},
{
label: 'Tolak',
icon: <X className="h-4 w-4" />,
iconClassName: 'text-destructive',
show:
submission.status === 'submitted' &&
canReject,
onClick: () => handleRejectClick(submission),
},
]}
/>
);
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(row.original.student_id),
},
]}
/>
),
},
];
}

View File

@ -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<CourseRegistrationSubmission | null>(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 (
<>
<Head title="Registrasi KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Registrasi KRS"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan registrasi dari notifikasi.
</p>
)
}
actions={
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
students={students}
academicTerms={academicTerms}
courseClasses={courseClasses}
/>
<PageHeader title="Registrasi KRS" />
<DataTable
columns={columns}
@ -201,257 +87,7 @@ export default function CourseRegistrationIndex({
/>
}
/>
<RejectForm
open={rejecting !== null}
onOpenChange={(open) => {
if (!open) {
setRejecting(null);
}
}}
submission={rejecting}
/>
</div>
</>
);
}
function RejectForm({
open,
onOpenChange,
submission,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
submission: CourseRegistrationSubmission | null;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tolak Registrasi KRS"
action={submission ? reject(submission.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
KRS milik{' '}
<span className="font-medium text-foreground">
{submission?.student?.user?.profile?.full_name ??
'mahasiswa ini'}
</span>{' '}
akan ditolak. Mahasiswa perlu memperbaiki dan mengajukan
ulang.
</p>
<div className="grid gap-2">
<Label htmlFor="reason">
Alasan Penolakan{' '}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reason"
name="reason"
placeholder="Jelaskan alasan penolakan"
/>
<InputError message={errors.reason} />
</div>
</div>
)}
</FormDialog>
);
}
function RegistrationFields({
errors,
students,
academicTerms,
courseClasses,
}: {
errors: Record<string, string>;
students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[];
}) {
const activeTermId = academicTerms.find((term) => term.is_active)?.id;
const [student, setStudent] = useState<CourseRegistrationStudent | null>(
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<number[]>([]);
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 (
<>
<div className="grid gap-2">
<Label>
Mahasiswa <span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="student_id"
value={student?.id ?? ''}
/>
<Combobox
items={students}
value={student}
onValueChange={setStudent}
itemToStringLabel={studentLabel}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxInput
placeholder="Pilih mahasiswa"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Mahasiswa tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(option: CourseRegistrationStudent) => (
<ComboboxItem key={option.id} value={option}>
{studentLabel(option)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.student_id} />
</div>
<div className="grid gap-2">
<Label>
Periode Akademik <span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={setAcademicTermId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem key={term.id} value={String(term.id)}>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2">
<Label>
Kelas Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
{!student || !academicTermId ? (
<p className="text-sm text-muted-foreground">
Pilih mahasiswa dan periode terlebih dahulu.
</p>
) : availableCourseClasses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Tidak ada kelas mata kuliah yang tersedia untuk semester
mahasiswa ini.
</p>
) : (
<div className="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2">
{availableCourseClasses.map((courseClass) => (
<label
key={courseClass.id}
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted"
>
<Checkbox
checked={selected.includes(courseClass.id)}
onCheckedChange={(checked) =>
toggle(courseClass.id, checked === true)
}
/>
<span className="text-sm">
{courseClassLabel(courseClass)}
</span>
</label>
))}
</div>
)}
{selected.map((courseClassId) => (
<input
key={courseClassId}
type="hidden"
name="course_class_ids[]"
value={courseClassId}
/>
))}
<InputError message={errors.course_class_ids} />
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
students,
academicTerms,
courseClasses,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Registrasi KRS"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<RegistrationFields
errors={errors}
students={students}
academicTerms={academicTerms}
courseClasses={courseClasses}
/>
</div>
)}
</FormDialog>
);
}

View File

@ -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 (
<div className="grid grid-cols-[auto_1fr] gap-x-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">: {value}</span>
</div>
);
}
function SignatureBox({
signatureUrl,
alt,
error,
editable,
}: {
signatureUrl: string | null;
alt: string;
error?: string;
editable: boolean;
}) {
if (!editable) {
if (!signatureUrl) {
return <div className="h-28 w-48" />;
}
return (
<div className="h-28 w-48 overflow-hidden rounded border bg-white">
<img
src={signatureUrl}
alt={alt}
className="h-full w-full object-contain"
/>
</div>
);
}
return <SignaturePad name="signature" error={error} compact />;
}
function ReviewerSignatureField({
signatureUrl,
alt,
canSign,
submitUrl,
}: {
signatureUrl: string | null;
alt: string;
canSign: boolean;
submitUrl: string;
}) {
const [file, setFile] = useState<File | null>(null);
const [processing, setProcessing] = useState(false);
if (signatureUrl) {
return (
<div className="h-28 w-48 overflow-hidden rounded border bg-white">
<img
src={signatureUrl}
alt={alt}
className="h-full w-full object-contain"
/>
</div>
);
}
if (!canSign) {
return <div className="h-28 w-48" />;
}
function handleSave() {
if (!file) {
return;
}
setProcessing(true);
router.patch(
submitUrl,
{ signature: file },
{
preserveScroll: true,
onFinish: () => setProcessing(false),
},
);
}
return (
<>
<Head title="Detail Registrasi KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Registrasi KRS"
actions={
<Button variant="outline" asChild>
<Link href={courseRegistrationIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>
{student?.user?.profile?.full_name ?? 'N/A'}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{student?.student_number} &middot;{' '}
{student?.department?.name ?? '-'} &middot;
Semester {student?.current_semester ?? '-'}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Periode:{' '}
{submission.academic_term
? formatAcademicTermLabel(
submission.academic_term,
)
: '-'}
</p>
</div>
<Badge
variant={
submission.status === 'approved'
? 'default'
: submission.status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[submission.status]}
</Badge>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Tanda Tangan
</CardTitle>
</CardHeader>
<CardContent>
{submission.signature_url ? (
<div className="w-fit rounded-md border bg-white p-2">
<img
src={submission.signature_url}
alt="Tanda tangan"
className="h-24"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
Tidak ada tanda tangan.
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Ditandatangani pada{' '}
{format(
new Date(submission.signed_at),
'd MMM yyyy, HH:mm',
)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Mata Kuliah (
{submission.course_registrations.length})
</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.course_registrations.map((registration) => (
<div
key={registration.id}
className="rounded-md border p-3"
>
<p className="font-medium">
{registration.course_class?.course?.name ??
'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{registration.course_class?.course?.code ??
'-'}
</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Riwayat</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.logs.map((log, index) => (
<LogBubble
key={log.id}
log={log}
isResubmission={
log.status === 'submitted' &&
submission.logs
.slice(0, index)
.some((l) => l.status === 'submitted')
}
/>
))}
</CardContent>
</Card>
</div>
</>
<div className="grid gap-1">
<SignaturePad name="_signature" compact onCapture={setFile} />
<Button
type="button"
size="sm"
disabled={!file || processing}
onClick={handleSave}
>
{processing ? 'Menyimpan...' : 'Simpan TTD'}
</Button>
</div>
);
}
@ -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({
</div>
);
}
function RejectDialog({
open,
onOpenChange,
submissionId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
submissionId: number;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tolak Registrasi KRS"
action={reject(submissionId)}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-2">
<Label htmlFor="reason">
Alasan Penolakan{' '}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reason"
name="reason"
placeholder="Jelaskan alasan penolakan"
/>
<InputError message={errors.reason} />
</div>
)}
</FormDialog>
);
}
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 (
<Form
action={save([studentId, semesterKey(semester)])}
options={{ preserveScroll: true }}
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<Card>
<CardContent className="p-6 sm:p-8">
<div className="flex flex-col items-center gap-1 text-center">
<h2 className="text-lg font-bold tracking-wide uppercase">
Kartu Rencana Studi
</h2>
<Badge
variant={
notYetSubmitted
? 'secondary'
: RegistrationStatusVariants[
submission.status
]
}
>
{notYetSubmitted
? 'Belum Diajukan'
: RegistrationStatusLabels[
submission.status
]}
</Badge>
</div>
<div className="mt-6 flex flex-col justify-between gap-4 sm:flex-row">
<div className="grid gap-1">
<InfoRow
label="Nama"
value={
student?.user?.profile?.full_name ??
'N/A'
}
/>
<InfoRow
label="NIM"
value={student?.student_number ?? '-'}
/>
</div>
<div className="grid gap-1 sm:text-right">
<InfoRow
label="Program Studi"
value={department?.name ?? '-'}
/>
<InfoRow
label="Dosen Pembimbing Akademik"
value={
student?.academic_advisor?.user
?.profile?.full_name ?? '-'
}
/>
</div>
</div>
<div className="mt-6 overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
No
</TableHead>
<TableHead>Mata Kuliah</TableHead>
<TableHead>Kode</TableHead>
<TableHead className="text-center">
SKS
</TableHead>
<TableHead className="w-[40px] text-center">
Ambil
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submission.course_registrations
.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
Belum ada mata kuliah yang
dipilih.
</TableCell>
</TableRow>
) : (
submission.course_registrations.map(
(
registration: CourseRegistrationSubmissionEntry,
index,
) => {
const course =
registration
.course_class
?.course;
const checked =
submission.has_registrations
? registration.is_registered
: isSemesterOpen;
return (
<TableRow
key={
registration.id
}
>
<TableCell>
{index + 1}
</TableCell>
<TableCell>
{course?.name ??
'N/A'}
</TableCell>
<TableCell>
{course?.code ??
'-'}
</TableCell>
<TableCell className="text-center">
{course?.credits ??
'-'}
</TableCell>
<TableCell className="text-center">
<Checkbox
name="course_class_ids[]"
value={
registration
.course_class
?.id
}
defaultChecked={
checked
}
disabled={
!isSemesterOpen ||
isViewOnly ||
isLocked
}
/>
</TableCell>
</TableRow>
);
},
)
)}
</TableBody>
{submission.course_registrations.length >
0 && (
<TableFooter>
<TableRow>
<TableCell
colSpan={3}
className="text-right font-medium"
>
Total SKS
</TableCell>
<TableCell className="text-center font-medium">
{totalCredits}
</TableCell>
<TableCell />
</TableRow>
</TableFooter>
)}
</Table>
</div>
<div className="mt-10 grid gap-6">
<div className="flex flex-col gap-8 sm:flex-row sm:justify-around">
<div className="grid gap-1 text-sm">
<p>Menyetujui,</p>
<p>Ketua Program Studi,</p>
<ReviewerSignatureField
signatureUrl={
submission.kaprodi_signature_url
}
alt="Tanda tangan Ketua Program Studi"
canSign={
canSignAsKaprodi &&
canReviewersSign
}
submitUrl={kaprodi_signature.url([
studentId,
semesterKey(semester),
])}
/>
<p className="font-semibold">
{kaprodi?.user?.profile
?.full_name ?? '-'}
</p>
<p className="text-xs text-muted-foreground">
NIDN.{' '}
{kaprodi?.lecturer_number ?? '-'}
</p>
</div>
<div className="grid gap-1 text-sm">
<p>Disetujui oleh,</p>
<p>Pembimbing Akademik,</p>
<ReviewerSignatureField
signatureUrl={
submission.advisor_signature_url
}
alt="Tanda tangan Pembimbing Akademik"
canSign={
canSignAsAdvisor &&
canReviewersSign
}
submitUrl={advisor_signature.url([
studentId,
semesterKey(semester),
])}
/>
<p className="font-semibold">
{advisor?.user?.profile
?.full_name ?? '-'}
</p>
<p className="text-xs text-muted-foreground">
NIDN.{' '}
{advisor?.lecturer_number ?? '-'}
</p>
</div>
<div className="grid gap-1 text-sm">
<p>Mahasiswa,</p>
<SignatureBox
signatureUrl={
submission.signature_url
}
alt="Tanda tangan mahasiswa"
error={errors.signature}
editable={!isViewOnly && !isLocked}
/>
<p className="font-semibold">
{student?.user?.profile
?.full_name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
NIM.{' '}
{student?.student_number ?? '-'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{!backHref && !isLocked && isSemesterOpen && (
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
<Send className="h-4 w-4" />
{processing ? 'Mengajukan...' : 'Ajukan'}
</Button>
</div>
)}
{canDecide && (
<div className="flex flex-col items-end gap-2">
<div className="flex gap-2">
<Button
type="button"
variant="destructive"
onClick={() => setRejecting(true)}
>
<X className="h-4 w-4" />
Tolak
</Button>
<Button
type="button"
disabled={cannotApprove}
onClick={handleApprove}
>
<Check className="h-4 w-4" />
Setujui
</Button>
</div>
{cannotApprove && (
<p className="text-xs text-muted-foreground">
{missingKaprodiSignature
? 'Menunggu tanda tangan Ketua Program Studi sebelum dapat disetujui.'
: 'Silakan tanda tangani KRS ini terlebih dahulu sebelum menyetujui.'}
</p>
)}
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Riwayat</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.logs.map((log, index) => (
<LogBubble
key={log.id}
log={log}
isResubmission={
log.status === 'submitted' &&
submission.logs
.slice(0, index)
.some(
(l) => l.status === 'submitted',
)
}
/>
))}
</CardContent>
</Card>
<RejectDialog
open={rejecting}
onOpenChange={setRejecting}
submissionId={submission.id}
/>
</>
)}
</Form>
);
}
type Props = {
submissions: Record<string, CourseRegistrationSubmission>;
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 (
<>
<Head title="Detail Registrasi KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Registrasi KRS"
actions={
backHref && (
<Button variant="outline" asChild>
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
)
}
/>
{semesters.length > 0 && (
<Tabs value={currentKey} onValueChange={setActiveSemester}>
<TabsList>
{semesters.map((semester) => (
<TabsTrigger
key={semesterKey(semester)}
value={semesterKey(semester)}
>
{semester !== null
? `Semester ${semester}`
: 'Lainnya'}
{!openSemesters.includes(semester ?? -1) &&
' (Ditutup)'}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{currentSubmission && (
<SemesterCard
key={currentKey}
semester={currentSemester}
submission={currentSubmission}
studentId={studentId}
isSemesterOpen={openSemesters.includes(
currentSemester ?? -1,
)}
backHref={backHref}
canReview={canReview}
canSignAsKaprodi={canSignAsKaprodi}
canSignAsAdvisor={canSignAsAdvisor}
/>
)}
</div>
</>
);
}

View File

@ -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(
</span>
),
},
{
id: 'open_semesters',
header: () => <span>Semester Dibuka</span>,
cell: ({ row }) => {
const openSemesters = row.original.open_semesters ?? [];
if (openSemesters.length === 0) {
return (
<span className="text-xs text-muted-foreground">
Semua Tertutup
</span>
);
}
return (
<div className="flex flex-wrap gap-1">
{[...openSemesters]
.sort((a, b) => a - b)
.map((semester) => (
<Badge key={semester} variant="outline">
{semester}
</Badge>
))}
</div>
);
},
},
{
accessorKey: 'is_active',
header: () => <span className="block text-center">Status</span>,

View File

@ -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<SubmissionSummary>[] = [
{
id: 'academic_term',
header: () => <span>Periode Akademik</span>,
cell: ({ row }) => {
const term = row.original.academic_term;
return term ? formatAcademicTermLabel(term) : '-';
},
},
{
id: 'count',
header: () => <span className="block text-center">Jumlah</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => row.original.course_registrations_count,
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'w-[140px] text-center',
headerClassName: 'w-[140px] text-center',
},
cell: ({ row }) => {
const status = row.original.status;
return (
<div className="flex justify-center">
<Badge
variant={
status === 'approved'
? 'default'
: status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[status]}
</Badge>
</div>
);
},
},
{
id: 'signed_at',
header: () => <span>Tanggal</span>,
cell: ({ row }) =>
format(new Date(row.original.signed_at), 'd MMM yyyy, HH:mm'),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(row.original.id),
},
]}
/>
),
},
];

View File

@ -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<number[]>(() => {
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 (
<>
<Head title="Isi KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Isi Kartu Rencana Studi"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle>
{student.user?.profile?.full_name ?? '-'}
</CardTitle>
<CardDescription>
{student.student_number} &middot;{' '}
{student.department?.name ?? '-'} &middot; Semester{' '}
{student.current_semester}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-1 text-sm text-muted-foreground">
<p>
Periode Akademik:{' '}
{formatAcademicTermLabel(academicTerm)}
</p>
<p>
Dosen Pembimbing Akademik:{' '}
{student.academic_advisor?.user?.profile
?.full_name ?? '-'}
</p>
</CardContent>
</Card>
{submission?.status === 'rejected' && (
<Card className="border-destructive/50">
<CardContent className="pt-6 text-sm">
<p className="flex items-center gap-2 font-medium text-destructive">
<XCircle className="h-4 w-4" />
KRS Anda ditolak
</p>
<p className="mt-1 text-muted-foreground">
{submission.rejection_reason ??
'Tidak ada alasan yang diberikan.'}
</p>
<p className="mt-1 text-muted-foreground">
Silakan perbaiki pilihan mata kuliah Anda dan
ajukan kembali di bawah ini.
</p>
</CardContent>
</Card>
)}
<Form action={store()}>
{({ errors, processing }) => (
<Card>
<CardHeader>
<CardTitle>
Pilih Mata Kuliah Semester{' '}
{student.current_semester}
</CardTitle>
<CardDescription>
Total {totalCredits} SKS dipilih
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{availableCourses.length === 0 && (
<p className="text-sm text-muted-foreground">
Belum ada mata kuliah/kelas untuk
semester ini pada periode aktif.
</p>
)}
{availableCourses.map(
({ course, course_class }) => (
<label
key={course.id}
className="flex items-start gap-3 rounded-md border p-3 has-disabled:opacity-50"
>
<Checkbox
className="mt-0.5"
checked={
course_class
? selected.includes(
course_class.id,
)
: false
}
disabled={!course_class}
onCheckedChange={(checked) =>
course_class &&
toggle(
course_class.id,
checked === true,
)
}
/>
<div>
<p className="font-medium">
{course.code} -{' '}
{course.name}
</p>
<p className="text-xs text-muted-foreground">
{course.credits} SKS
&middot;{' '}
{course_class
? (course_class.lecturer
?.user?.profile
?.full_name ??
'Dosen belum ditentukan')
: 'Kelas belum dibuka'}
</p>
</div>
</label>
),
)}
{selected.map((courseClassId) => (
<input
key={courseClassId}
type="hidden"
name="course_class_ids[]"
value={courseClassId}
/>
))}
<InputError message={errors.course_class_ids} />
</CardContent>
<CardContent className="border-t pt-6">
<SignaturePad
name="signature"
error={errors.signature}
/>
</CardContent>
<CardFooter className="justify-end">
<Button
type="submit"
disabled={
processing || selected.length === 0
}
>
{submission?.status === 'rejected'
? 'Ajukan Ulang KRS'
: 'Tanda Tangan KRS'}
</Button>
</CardFooter>
</Card>
)}
</Form>
</div>
</>
);
}

View File

@ -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 (
<>
<Head title="KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Kartu Rencana Studi"
description={
academicTerm && (
<p className="mt-1 text-sm text-muted-foreground">
Periode aktif: {academicTerm.name} (
{academicTerm.semester === 'odd'
? 'Ganjil'
: 'Genap'}
)
</p>
)
}
/>
{!academicTerm && (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Belum ada periode akademik yang aktif. Silakan
hubungi admin.
</CardContent>
</Card>
)}
{academicTerm && payment && !payment.is_paid && (
<Card className="border-destructive/50">
<CardContent className="pt-6 text-sm">
<p className="font-medium text-destructive">
Anda belum bisa mengisi KRS periode ini.
</p>
<p className="mt-1 text-muted-foreground">
{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.'}
</p>
</CardContent>
</Card>
)}
{academicTerm && payment?.is_paid && canSubmit && (
<Card
className={
activeSubmission?.status === 'rejected'
? 'border-destructive/50'
: undefined
}
>
<CardContent className="flex flex-col gap-3 pt-6 sm:flex-row sm:items-center sm:justify-between">
<div>
{activeSubmission?.status === 'rejected' ? (
<>
<p className="flex items-center gap-2 text-sm font-medium text-destructive">
<XCircle className="h-4 w-4" />
KRS Anda untuk periode ini ditolak
</p>
<p className="mt-1 text-sm text-muted-foreground">
{activeSubmission.rejection_reason ??
'Tidak ada alasan yang diberikan.'}{' '}
Silakan perbaiki dan ajukan kembali.
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
Anda belum mengisi KRS untuk periode
aktif ini.
</p>
)}
</div>
<Button asChild>
<Link href={create.url()}>
{activeSubmission?.status === 'rejected'
? 'Ajukan Ulang KRS'
: 'Isi KRS'}
</Link>
</Button>
</CardContent>
</Card>
)}
<DataTable columns={submissionColumns} data={submissions} />
</div>
</>
);
}

View File

@ -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 (
<>
<Head title="Detail KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Kartu Rencana Studi"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>
{submission.academic_term
? formatAcademicTermLabel(
submission.academic_term,
)
: '-'}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
Ditandatangani pada{' '}
{format(
new Date(submission.signed_at),
'd MMM yyyy, HH:mm',
)}
</p>
</div>
<Badge
variant={
submission.status === 'approved'
? 'default'
: submission.status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[submission.status]}
</Badge>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Tanda Tangan
</CardTitle>
</CardHeader>
<CardContent>
{submission.signature_url ? (
<div className="w-fit rounded-md border bg-white p-2">
<img
src={submission.signature_url}
alt="Tanda tangan"
className="h-24"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
Tidak ada tanda tangan.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Mata Kuliah (
{submission.course_registrations.length})
</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.course_registrations.map((registration) => (
<div
key={registration.id}
className="rounded-md border p-3"
>
<p className="font-medium">
{registration.course_class?.course?.code ??
'-'}{' '}
-{' '}
{registration.course_class?.course?.name ??
'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{registration.course_class?.course
?.credits ?? '-'}{' '}
SKS &middot;{' '}
{registration.course_class?.lecturer?.user
?.profile?.full_name ??
'Dosen belum ditentukan'}
</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Riwayat</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.logs.map((log, index) => (
<LogBubble
key={log.id}
log={log}
isResubmission={
log.status === 'submitted' &&
submission.logs
.slice(0, index)
.some((l) => l.status === 'submitted')
}
/>
))}
</CardContent>
</Card>
</div>
</>
);
}
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 (
<div className={`rounded-lg border px-3 py-2 ${colorClasses}`}>
<p className="text-sm">{message}</p>
<p className="mt-1 text-xs text-muted-foreground">
{format(new Date(log.created_at), 'd MMM yyyy, HH:mm')}
</p>
</div>
);
}

View File

@ -15,63 +15,78 @@ export const RegistrationStatusLabels: Record<RegistrationStatus, string> = {
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);
}

View File

@ -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'])