feat: add course registration management for admin and student
Some checks failed
tests / ci (pull_request) Has been cancelled

- Implemented CourseRegistrationShow component for admin to view registration details.
- Added course registration creation and listing for students.
- Enhanced student registration edit and create forms with improved error handling.
- Introduced new columns for course registration submissions in student view.
- Updated routes for course registration management in admin and student contexts.
- Added logging and status handling for course registration submissions.
- Improved type definitions for course registration and user roles.
This commit is contained in:
Yoga Pangestu 2026-08-30 20:50:27 +07:00
parent 62da33936b
commit 8336504032
31 changed files with 2060 additions and 502 deletions

View File

@ -4,14 +4,16 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CourseRegistrationRequest; use App\Http\Requests\Admin\Manage\CourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\RejectCourseRegistrationRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\CourseRegistration; use App\Models\CourseRegistrationSubmission;
use App\Models\Student;
use App\Services\Admin\Manage\CourseClassService; use App\Services\Admin\Manage\CourseClassService;
use App\Services\Admin\Manage\CourseRegistrationService; use App\Services\Admin\Manage\CourseRegistrationService;
use App\Services\Admin\Master\AcademicTermService; use App\Services\Admin\Master\AcademicTermService;
use App\Services\Admin\Users\LecturerService;
use App\Services\Admin\Users\StudentService; use App\Services\Admin\Users\StudentService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -22,7 +24,6 @@ public function __construct(
private readonly StudentService $studentService, private readonly StudentService $studentService,
private readonly AcademicTermService $academicTermService, private readonly AcademicTermService $academicTermService,
private readonly CourseClassService $courseClassService, private readonly CourseClassService $courseClassService,
private readonly LecturerService $lecturerService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -36,13 +37,84 @@ public function index(PaginatedRequest $request): Response
'students' => $this->studentService->getAllForSelect(), 'students' => $this->studentService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(), 'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClasses' => $this->courseClassService->getAllForSelect(), 'courseClasses' => $this->courseClassService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(),
'filters' => $request->only(['status', 'academic_term_id']), 'filters' => $request->only(['status', 'academic_term_id']),
]); ]);
} }
public function mine(Request $request): Response
{
$student = $this->currentStudent($request);
$term = $this->academicTermService->getActive();
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
$activeSubmission = $term ? $this->service->currentSubmission($student, $term) : null;
return Inertia::render('student/course-registrations/index', [
'academicTerm' => $term,
'payment' => $payment,
'canSubmit' => $this->service->canSubmit($activeSubmission),
'activeSubmission' => $activeSubmission,
'submissions' => $this->service->submissionsFor($student),
]);
}
public function create(Request $request): Response|RedirectResponse
{
$student = $this->currentStudent($request);
$student->load(['user.profile', 'department', 'academicAdvisor.user.profile']);
$term = $this->academicTermService->getActive();
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
$submission = $term ? $this->service->currentSubmission($student, $term) : null;
if (! $term || ! $payment['is_paid'] || ! $this->service->canSubmit($submission)) {
return to_route('student.course-registrations.index');
}
return Inertia::render('student/course-registrations/create', [
'student' => $student,
'academicTerm' => $term,
'submission' => $submission,
'availableCourses' => $this->service->availableCourseClasses($student, $term),
]);
}
public function show(Request $request, CourseRegistrationSubmission $submission): Response
{
if ($request->routeIs('student.*')) {
$student = $this->currentStudent($request);
abort_if($submission->student_id !== $student->id, 403);
return Inertia::render('student/course-registrations/show', [
'submission' => $this->service->withDetails($submission),
]);
}
return Inertia::render('admin/manage/course-registrations/show', [
'submission' => $this->service->withDetails($submission),
]);
}
public function store(CourseRegistrationRequest $request): RedirectResponse public function store(CourseRegistrationRequest $request): RedirectResponse
{ {
if ($request->routeIs('student.*')) {
$student = $this->currentStudent($request);
$term = $this->academicTermService->getActive();
$submission = $this->service->sign(
$student,
$term,
$request->validated('course_class_ids'),
$request->file('signature'),
);
Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditandatangani.']);
return to_route('student.course-registrations.show', $submission);
}
$this->service->create($request->validated()); $this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']);
@ -50,19 +122,26 @@ public function store(CourseRegistrationRequest $request): RedirectResponse
return to_route('admin.manage.course-registrations.index'); return to_route('admin.manage.course-registrations.index');
} }
public function update(CourseRegistrationRequest $request, CourseRegistration $courseRegistration): RedirectResponse public function approve(CourseRegistrationSubmission $submission): RedirectResponse
{ {
$this->service->update($courseRegistration, $request->validated()); $this->service->approve($submission);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil diperbarui.']); return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disetujui.'])->back();
return to_route('admin.manage.course-registrations.index');
} }
public function destroy(CourseRegistration $courseRegistration): RedirectResponse public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
{ {
$this->service->delete($courseRegistration); $this->service->reject($submission, $request->validated('reason'));
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil dihapus.'])->back(); return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
}
private function currentStudent(Request $request): Student
{
$student = $request->user()->student;
abort_if(! $student, 403);
return $student;
} }
} }

View File

@ -41,7 +41,7 @@ public function share(Request $request): array
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'auth' => [ 'auth' => [
'user' => $request->user(), 'user' => $request->user()?->load('roles:id,name'),
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'unreadNotificationsCount' => fn () => $request->user() 'unreadNotificationsCount' => fn () => $request->user()

View File

@ -2,7 +2,8 @@
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage;
use App\Enums\RegistrationStatus; use App\Services\Admin\Manage\CourseRegistrationService;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -10,40 +11,65 @@ class CourseRegistrationRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
if (! $this->routeIs('student.*')) {
return true; return true;
} }
$student = $this->user()->student;
if (! $student) {
return false;
}
$term = app(AcademicTermService::class)->getActive();
if (! $term) {
return false;
}
$service = app(CourseRegistrationService::class);
$submission = $service->currentSubmission($student, $term);
if (! $service->canSubmit($submission)) {
return false;
}
return $service->paymentStatus($student, $term)['is_paid'];
}
public function rules(): array public function rules(): array
{ {
$registration = $this->route('course_registration'); if ($this->routeIs('student.*')) {
$student = $this->user()->student;
$term = app(AcademicTermService::class)->getActive();
$allowedIds = $student && $term
? app(CourseRegistrationService::class)->allowedCourseClassIds($student, $term)
: [];
return [
'course_class_ids' => ['required', 'array', 'min:1'],
'course_class_ids.*' => ['integer', Rule::in($allowedIds)],
'signature' => ['required', 'image', 'max:2048'],
];
}
return [ return [
'student_id' => ['required', 'integer', Rule::exists('students', 'id')], 'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], 'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'course_class_id' => [ 'course_class_ids' => ['required', 'array', 'min:1'],
'required', 'course_class_ids.*' => [
'integer', 'integer',
Rule::exists('course_classes', 'id'), Rule::exists('course_classes', 'id'),
Rule::unique('course_registrations') Rule::unique('course_registrations', 'course_class_id')
->where('student_id', $this->input('student_id')) ->where('student_id', $this->input('student_id')),
->ignore($registration?->id),
], ],
'status' => ['nullable', 'string', Rule::in(RegistrationStatus::values())],
'approved_by' => ['nullable', 'integer', Rule::exists('lecturers', 'id')],
]; ];
} }
public function attributes(): array public function attributes(): array
{ {
return [ return [
'course_class_id' => 'kelas mata kuliah', 'course_class_ids' => 'mata kuliah',
];
}
public function messages(): array
{
return [
'course_class_id.unique' => 'Mahasiswa ini sudah terdaftar pada kelas mata kuliah tersebut.',
]; ];
} }
} }

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
class RejectCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'reason.required' => 'Alasan penolakan wajib diisi.',
];
}
}

View File

@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Enums\RegistrationStatus;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -13,13 +12,6 @@ class CourseRegistration extends Model
{ {
use HasFactory; use HasFactory;
protected function casts(): array
{
return [
'status' => RegistrationStatus::class,
];
}
public function student(): BelongsTo public function student(): BelongsTo
{ {
return $this->belongsTo(Student::class); return $this->belongsTo(Student::class);
@ -35,8 +27,8 @@ public function courseClass(): BelongsTo
return $this->belongsTo(CourseClass::class); return $this->belongsTo(CourseClass::class);
} }
public function approver(): BelongsTo public function submission(): BelongsTo
{ {
return $this->belongsTo(Lecturer::class, 'approved_by'); return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
} }
} }

View File

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use App\Enums\RegistrationStatus;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
#[Appends(['signature_url'])]
class CourseRegistrationSubmission extends Model implements HasMedia
{
use InteractsWithMedia;
protected function casts(): array
{
return [
'status' => RegistrationStatus::class,
'signed_at' => 'datetime',
'reviewed_at' => 'datetime',
];
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('signature')->singleFile();
}
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function academicTerm(): BelongsTo
{
return $this->belongsTo(AcademicTerm::class);
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by');
}
public function courseRegistrations(): HasMany
{
return $this->hasMany(CourseRegistration::class, 'submission_id');
}
public function logs(): HasMany
{
return $this->hasMany(CourseRegistrationSubmissionLog::class, 'submission_id');
}
protected function signatureUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('signature') ?: null,
);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Enums\RegistrationStatus;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class CourseRegistrationSubmissionLog extends Model
{
protected function casts(): array
{
return [
'status' => RegistrationStatus::class,
];
}
public function submission(): BelongsTo
{
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
}

View File

@ -62,6 +62,11 @@ public function courseRegistrations(): HasMany
return $this->hasMany(CourseRegistration::class); return $this->hasMany(CourseRegistration::class);
} }
public function courseRegistrationSubmissions(): HasMany
{
return $this->hasMany(CourseRegistrationSubmission::class);
}
public function letterRequests(): HasMany public function letterRequests(): HasMany
{ {
return $this->hasMany(LetterRequest::class); return $this->hasMany(LetterRequest::class);

View File

@ -10,8 +10,8 @@ class CourseClassService
{ {
public function getAllForSelect(): Collection public function getAllForSelect(): Collection
{ {
return CourseClass::select(['id', 'course_id']) return CourseClass::select(['id', 'course_id', 'academic_term_id'])
->with('course:id,code,name') ->with('course:id,code,name,semester_number,department_id')
->get(); ->get();
} }

View File

@ -3,22 +3,33 @@
namespace App\Services\Admin\Manage; namespace App\Services\Admin\Manage;
use App\Enums\RegistrationStatus; use App\Enums\RegistrationStatus;
use App\Models\AcademicTerm;
use App\Models\ClassEnrollment; use App\Models\ClassEnrollment;
use App\Models\Course;
use App\Models\CourseClass;
use App\Models\CourseRegistration; use App\Models\CourseRegistration;
use App\Models\CourseRegistrationSubmission;
use App\Models\Student;
use App\Models\TuitionInvoice;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class CourseRegistrationService class CourseRegistrationService
{ {
public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator
{ {
return CourseRegistration::query() return CourseRegistrationSubmission::query()
->select(['id', 'student_id', 'academic_term_id', 'course_class_id', 'status', 'approved_by', 'created_at']) ->select(['id', 'student_id', 'academic_term_id', 'status', 'signed_at', 'rejection_reason', 'reviewed_by', 'reviewed_at'])
->with([ ->with([
'student.user.profile', 'student.user.profile',
'student.department', 'student.department',
'academicTerm:id,name,semester,start_date,end_date', 'academicTerm:id,name,semester,start_date,end_date',
'courseClass.course:id,code,name', 'reviewer.profile',
'approver.user.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}%") ->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}%")))) ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
@ -28,46 +39,63 @@ public function paginated(int $perPage = 25, string $search = '', ?string $statu
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): CourseRegistration /**
* Admin-created registration: additively attach course classes to a
* student's submission for a term, without disturbing an existing
* submission's review status.
*
* @param array{student_id: int, academic_term_id: int, course_class_ids: array<int, int>} $data
*/
public function create(array $data): CourseRegistrationSubmission
{ {
$registration = CourseRegistration::create([ return DB::transaction(function () use ($data) {
$submission = CourseRegistrationSubmission::firstOrCreate(
[
'student_id' => $data['student_id'], 'student_id' => $data['student_id'],
'academic_term_id' => $data['academic_term_id'], 'academic_term_id' => $data['academic_term_id'],
'course_class_id' => $data['course_class_id'], ],
'status' => $data['status'] ?? RegistrationStatus::Submitted->value, [
'approved_by' => $data['approved_by'] ?? null, 'status' => RegistrationStatus::Submitted,
'signed_at' => now(),
],
);
if ($submission->wasRecentlyCreated) {
$submission->logs()->create([
'status' => RegistrationStatus::Submitted,
'actor_id' => auth()->id(),
]);
}
foreach ($data['course_class_ids'] as $courseClassId) {
CourseRegistration::create([
'student_id' => $data['student_id'],
'academic_term_id' => $data['academic_term_id'],
'course_class_id' => $courseClassId,
'submission_id' => $submission->id,
]);
}
return $submission;
});
}
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
{
return DB::transaction(function () use ($submission) {
$submission->update([
'status' => RegistrationStatus::Approved,
'rejection_reason' => null,
'reviewed_by' => auth()->id(),
'reviewed_at' => now(),
]); ]);
$this->syncEnrollment($registration); $submission->logs()->create([
'status' => RegistrationStatus::Approved,
return $registration; 'actor_id' => auth()->id(),
} ]);
public function update(CourseRegistration $registration, array $data): CourseRegistration
{
$registration->student_id = $data['student_id'];
$registration->academic_term_id = $data['academic_term_id'];
$registration->course_class_id = $data['course_class_id'];
$registration->status = $data['status'] ?? RegistrationStatus::Submitted->value;
$registration->approved_by = $data['approved_by'] ?? null;
$registration->update();
$this->syncEnrollment($registration);
return $registration;
}
public function delete(CourseRegistration $registration): bool
{
return $registration->delete();
}
private function syncEnrollment(CourseRegistration $registration): void
{
if ($registration->status !== RegistrationStatus::Approved) {
return;
}
foreach ($submission->courseRegistrations as $registration) {
ClassEnrollment::firstOrCreate( ClassEnrollment::firstOrCreate(
[ [
'course_class_id' => $registration->course_class_id, 'course_class_id' => $registration->course_class_id,
@ -78,4 +106,188 @@ private function syncEnrollment(CourseRegistration $registration): void
], ],
); );
} }
return $submission;
});
}
public function reject(CourseRegistrationSubmission $submission, string $reason): CourseRegistrationSubmission
{
return DB::transaction(function () use ($submission, $reason) {
$submission->update([
'status' => RegistrationStatus::Rejected,
'rejection_reason' => $reason,
'reviewed_by' => auth()->id(),
'reviewed_at' => now(),
]);
$submission->logs()->create([
'status' => RegistrationStatus::Rejected,
'reason' => $reason,
'actor_id' => auth()->id(),
]);
return $submission;
});
}
/**
* @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

@ -7,6 +7,7 @@
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Spatie\Permission\Middleware\RoleMiddleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@ -22,6 +23,10 @@
HandleInertiaRequests::class, HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class, AddLinkHeadersForPreloadedAssets::class,
]); ]);
$middleware->alias([
'role' => RoleMiddleware::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(

View File

@ -0,0 +1,31 @@
<?php
use App\Enums\RegistrationStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('course_registration_submissions', function (Blueprint $table) {
$table->id();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
$table->enum('status', RegistrationStatus::values())->default(RegistrationStatus::Submitted->value);
$table->timestamp('signed_at');
$table->text('rejection_reason')->nullable();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->unique(['student_id', 'academic_term_id'], 'course_reg_submissions_student_term_unique');
});
}
public function down(): void
{
Schema::dropIfExists('course_registration_submissions');
}
};

View File

@ -1,6 +1,5 @@
<?php <?php
use App\Enums\RegistrationStatus;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@ -14,8 +13,7 @@ public function up(): void
$table->foreignId('student_id')->constrained()->cascadeOnDelete(); $table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete(); $table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
$table->foreignId('course_class_id')->constrained()->cascadeOnDelete(); $table->foreignId('course_class_id')->constrained()->cascadeOnDelete();
$table->enum('status', RegistrationStatus::values())->nullable()->default(RegistrationStatus::Submitted->value); $table->foreignId('submission_id')->constrained('course_registration_submissions')->cascadeOnDelete();
$table->foreignId('approved_by')->nullable()->constrained('lecturers')->nullOnDelete();
$table->timestamps(); $table->timestamps();
$table->unique(['student_id', 'course_class_id']); $table->unique(['student_id', 'course_class_id']);

View File

@ -0,0 +1,26 @@
<?php
use App\Enums\RegistrationStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('course_registration_submission_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('submission_id')->constrained('course_registration_submissions')->cascadeOnDelete();
$table->enum('status', RegistrationStatus::values());
$table->text('reason')->nullable();
$table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('course_registration_submission_logs');
}
};

View File

@ -2,32 +2,80 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\RegistrationStatus;
use App\Models\CourseRegistration; use App\Models\CourseRegistration;
use App\Models\CourseRegistrationSubmission;
use App\Models\CourseRegistrationSubmissionLog;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class CourseRegistrationSeeder extends Seeder class CourseRegistrationSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
CourseRegistration::insert([ $approved = CourseRegistrationSubmission::create([
[ 'student_id' => 1,
'academic_term_id' => 2,
'status' => RegistrationStatus::Approved,
'signed_at' => '2026-01-20 08:00:00',
'reviewed_by' => 1,
'reviewed_at' => '2026-01-20 13:00:00',
'created_at' => '2026-01-20 08:00:00',
'updated_at' => '2026-01-20 13:00:00',
]);
CourseRegistration::create([
'student_id' => 1, 'student_id' => 1,
'academic_term_id' => 2, 'academic_term_id' => 2,
'course_class_id' => 1, 'course_class_id' => 1,
'status' => 'approved', 'submission_id' => $approved->id,
'approved_by' => 1,
'created_at' => '2026-01-20 08:00:00', 'created_at' => '2026-01-20 08:00:00',
'updated_at' => '2026-01-20 13:00:00', 'updated_at' => '2026-01-20 08:00:00',
]);
CourseRegistrationSubmissionLog::insert([
[
'submission_id' => $approved->id,
'status' => RegistrationStatus::Submitted->value,
'reason' => null,
'actor_id' => 1,
'created_at' => '2026-01-20 08:00:00',
'updated_at' => '2026-01-20 08:00:00',
], ],
[ [
'submission_id' => $approved->id,
'status' => RegistrationStatus::Approved->value,
'reason' => null,
'actor_id' => 1,
'created_at' => '2026-01-20 13:00:00',
'updated_at' => '2026-01-20 13:00:00',
],
]);
$submitted = CourseRegistrationSubmission::create([
'student_id' => 2,
'academic_term_id' => 2,
'status' => RegistrationStatus::Submitted,
'signed_at' => '2026-01-20 08:10:00',
'created_at' => '2026-01-20 08:10:00',
'updated_at' => '2026-01-20 08:10:00',
]);
CourseRegistration::create([
'student_id' => 2, 'student_id' => 2,
'academic_term_id' => 2, 'academic_term_id' => 2,
'course_class_id' => 2, 'course_class_id' => 2,
'status' => 'submitted', 'submission_id' => $submitted->id,
'approved_by' => null, 'created_at' => '2026-01-20 08:10:00',
'updated_at' => '2026-01-20 08:10:00',
]);
CourseRegistrationSubmissionLog::create([
'submission_id' => $submitted->id,
'status' => RegistrationStatus::Submitted->value,
'reason' => null,
'actor_id' => 2,
'created_at' => '2026-01-20 08:10:00', 'created_at' => '2026-01-20 08:10:00',
'updated_at' => '2026-01-20 08:10:00', 'updated_at' => '2026-01-20 08:10:00',
],
]); ]);
} }
} }

BIN
docs/reference/KRS.xlsx Normal file

Binary file not shown.

49
package-lock.json generated
View File

@ -54,6 +54,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-signature-canvas": "^1.0.7",
"recharts": "3.8.0", "recharts": "3.8.0",
"shadcn": "^4.16.1", "shadcn": "^4.16.1",
"sonner": "^2.0.0", "sonner": "^2.0.0",
@ -70,6 +71,7 @@
"@laravel/vite-plugin-wayfinder": "^0.1.3", "@laravel/vite-plugin-wayfinder": "^0.1.3",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react-signature-canvas": "^1.0.7",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@ -4335,6 +4337,24 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-signature-canvas": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/react-signature-canvas/-/react-signature-canvas-1.0.7.tgz",
"integrity": "sha512-0ulzaUvcIQ0HdNB5fHj+KE7ztWhlhYRsi65TdPIRj/t+FD5Rr8NJKBv4/xLViz7HsUh/tgqsoyKeARrm9+gPIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@types/signature_pad": "<3"
}
},
"node_modules/@types/signature_pad": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz",
"integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -9137,7 +9157,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@ -10231,7 +10250,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@ -10674,6 +10692,21 @@
} }
} }
}, },
"node_modules/react-signature-canvas": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.0.7.tgz",
"integrity": "sha512-yo0x0uTMVmcClaqryuQu6F8xEVapk5zdM9/nuQ7GalDJWccoVNZPMmCFGK7U5r3I0mrEHPB9E5/rFSYUgZcfmA==",
"license": "Apache-2.0",
"dependencies": {
"signature_pad": "^2.3.2",
"trim-canvas": "^0.1.0"
},
"peerDependencies": {
"prop-types": "^15.5.8",
"react": "0.14 - 19",
"react-dom": "0.14 - 19"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -11383,6 +11416,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/signature_pad": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz",
"integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==",
"license": "MIT"
},
"node_modules/sisteransi": { "node_modules/sisteransi": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@ -11809,6 +11848,12 @@
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
}, },
"node_modules/trim-canvas": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
"integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==",
"license": "Apache-2.0"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",

View File

@ -17,6 +17,7 @@
"@laravel/vite-plugin-wayfinder": "^0.1.3", "@laravel/vite-plugin-wayfinder": "^0.1.3",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react-signature-canvas": "^1.0.7",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@ -78,6 +79,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-signature-canvas": "^1.0.7",
"recharts": "3.8.0", "recharts": "3.8.0",
"shadcn": "^4.16.1", "shadcn": "^4.16.1",
"sonner": "^2.0.0", "sonner": "^2.0.0",

View File

@ -0,0 +1,97 @@
import { Eraser } from 'lucide-react';
import { useRef } from 'react';
import SignatureCanvasImport from 'react-signature-canvas';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
// react-signature-canvas ships as a CommonJS/UMD bundle. Vite's dev-server
// pre-bundling wraps its `module.exports` (itself `{ __esModule: true,
// default: SignatureCanvas }`) as the ESM default export, so the default
// import resolves one layer too shallow. Unwrap it before use.
const SignatureCanvas = ((
SignatureCanvasImport as unknown as {
default?: typeof SignatureCanvasImport;
}
).default ?? SignatureCanvasImport) as typeof SignatureCanvasImport;
type SignaturePadProps = {
name: string;
error?: string;
};
export function SignaturePad({ name, error }: SignaturePadProps) {
const padRef = useRef<SignatureCanvasImport>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function syncFileInput() {
const pad = padRef.current;
const input = fileInputRef.current;
if (!pad || !input || pad.isEmpty()) {
return;
}
pad.getTrimmedCanvas().toBlob((blob: Blob | null) => {
if (!blob) {
return;
}
const file = new File([blob], 'signature.png', {
type: 'image/png',
});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
}, 'image/png');
}
function handleClear() {
padRef.current?.clear();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
return (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>
Tanda Tangan <span className="text-destructive">*</span>
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClear}
>
<Eraser className="h-4 w-4" />
Hapus
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-white">
<SignatureCanvas
ref={padRef}
penColor="#0f172a"
canvasProps={{
className:
'h-[180px] w-full cursor-crosshair touch-none',
}}
onEnd={syncFileInput}
/>
</div>
<input
ref={fileInputRef}
type="file"
name={name}
className="hidden"
/>
<p className="text-xs text-muted-foreground">
Gambar tanda tangan Anda pada kotak di atas menggunakan mouse
atau layar sentuh.
</p>
<InputError message={error} />
</div>
);
}

View File

@ -1,30 +1,26 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns'; import { Check, Eye, X } from 'lucide-react';
import { Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions'; import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { formatAcademicTermLabel } from '@/types/academic-term'; import { show } from '@/routes/admin/manage/course-registrations';
import type { import type { CourseRegistrationSubmission } from '@/types/course-registration';
CourseRegistration,
RegistrationStatus,
} from '@/types/course-registration';
import { RegistrationStatusLabels } from '@/types/course-registration'; import { RegistrationStatusLabels } from '@/types/course-registration';
export type { CourseRegistration } from '@/types/course-registration'; export type { CourseRegistrationSubmission } from '@/types/course-registration';
type CreateColumnsParams = { type CreateColumnsParams = {
handleEdit: (registration: CourseRegistration) => void; handleApprove: (submission: CourseRegistrationSubmission) => void;
handleDeleteClick: (registration: CourseRegistration) => void; handleRejectClick: (submission: CourseRegistrationSubmission) => void;
}; };
export function createCourseRegistrationColumns( export function createCourseRegistrationColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<CourseRegistration>[] { ): ColumnDef<CourseRegistrationSubmission>[] {
const { handleEdit, handleDeleteClick } = params; const { handleApprove, handleRejectClick } = params;
return [ return [
{ {
accessorKey: 'student.student_number', id: 'student',
header: () => <span>Mahasiswa</span>, header: () => <span>Mahasiswa</span>,
cell: ({ row }) => { cell: ({ row }) => {
const student = row.original.student; const student = row.original.student;
@ -43,101 +39,86 @@ export function createCourseRegistrationColumns(
}, },
}, },
{ {
accessorKey: 'course_class.course.name', id: 'semester',
header: () => <span>Kelas Mata Kuliah</span>, header: () => <span className="block text-center">Semester</span>,
cell: ({ row }) => { meta: {
const courseClass = row.original.course_class; className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
return (
<div>
<p className="font-medium">
{courseClass?.course?.name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{courseClass?.course?.code}
</p>
</div>
);
}, },
cell: ({ row }) => row.original.student?.current_semester ?? '-',
}, },
{ {
accessorKey: 'academic_term.name', id: 'count',
header: () => <span>Periode</span>, header: () => <span className="block text-center">Jumlah</span>,
cell: ({ row }) => { meta: {
const term = row.original.academic_term; className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
return term ? formatAcademicTermLabel(term) : '-';
}, },
cell: ({ row }) => row.original.course_registrations.length,
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
header: () => <span className="block text-center">Status</span>, header: () => <span className="block text-center">Status</span>,
meta: { meta: {
className: 'w-[130px] text-center', className: 'w-[140px] text-center',
headerClassName: 'w-[130px] text-center', headerClassName: 'w-[140px] text-center',
}, },
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue( const status = row.original.status;
'status',
) as RegistrationStatus | null;
const variant = return (
<div className="flex justify-center">
<Badge
variant={
status === 'approved' status === 'approved'
? 'default' ? 'default'
: status === 'rejected' : status === 'rejected'
? 'destructive' ? 'destructive'
: 'outline'; : 'outline'
}
return ( >
<div className="flex justify-center">
{status ? (
<Badge variant={variant}>
{RegistrationStatusLabels[status]} {RegistrationStatusLabels[status]}
</Badge> </Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div> </div>
); );
}, },
}, },
{
accessorKey: 'approver.user.profile.full_name',
header: () => <span>Disetujui Oleh</span>,
cell: ({ row }) =>
row.original.approver?.user?.profile?.full_name ?? '-',
},
{
accessorKey: 'created_at',
header: () => <span>Diajukan</span>,
cell: ({ row }) =>
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
},
{ {
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
className: 'w-[100px] text-center', className: 'w-[130px] text-center',
headerClassName: 'w-[100px] text-center', headerClassName: 'w-[130px] text-center',
}, },
cell: ({ row }) => ( cell: ({ row }) => {
const submission = row.original;
return (
<RowActions <RowActions
actions={[ actions={[
{ {
label: 'Edit', label: 'Lihat Detail',
icon: <Pencil className="h-4 w-4" />, icon: <Eye className="h-4 w-4" />,
onClick: () => handleEdit(row.original), href: show.url(submission.id),
}, },
{ {
label: 'Hapus', label: 'Setujui',
icon: ( icon: <Check className="h-4 w-4" />,
<Trash2 className="h-4 w-4 text-destructive" /> iconClassName: 'text-primary',
), show: submission.status === 'submitted',
onClick: () => handleDeleteClick(row.original), onClick: () => handleApprove(submission),
},
{
label: 'Tolak',
icon: <X className="h-4 w-4" />,
iconClassName: 'text-destructive',
show: submission.status === 'submitted',
onClick: () => handleRejectClick(submission),
}, },
]} ]}
/> />
), );
},
}, },
]; ];
} }

View File

@ -3,13 +3,21 @@ import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog'; import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog'; import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog'; import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; 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 { Label } from '@/components/ui/label';
import { import {
Select, Select,
@ -18,18 +26,19 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
index as courseRegistrationIndex, index as courseRegistrationIndex,
destroy, approve,
reject,
store, store,
update,
} from '@/routes/admin/manage/course-registrations'; } from '@/routes/admin/manage/course-registrations';
import { formatAcademicTermLabel } from '@/types/academic-term'; import { formatAcademicTermLabel } from '@/types/academic-term';
import type { import type {
CourseRegistration,
CourseRegistrationCourseClass, CourseRegistrationCourseClass,
CourseRegistrationStudent, CourseRegistrationStudent,
CourseRegistrationSubmission,
} from '@/types/course-registration'; } from '@/types/course-registration';
import { import {
RegistrationStatuses, RegistrationStatuses,
@ -37,16 +46,16 @@ import {
} from '@/types/course-registration'; } from '@/types/course-registration';
import { createCourseRegistrationColumns } from './columns'; import { createCourseRegistrationColumns } from './columns';
type AcademicTermOption = { id: number; name: string; semester: string }; type AcademicTermOption = {
type LecturerOption = {
id: number; id: number;
lecturer_number: string; name: string;
user: { profile: { full_name: string } | null } | null; semester: string;
is_active: boolean;
}; };
type Props = { type Props = {
registrations: { registrations: {
data: CourseRegistration[]; data: CourseRegistrationSubmission[];
current_page: number; current_page: number;
last_page: number; last_page: number;
per_page: number; per_page: number;
@ -55,7 +64,6 @@ type Props = {
students: CourseRegistrationStudent[]; students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[]; academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[]; courseClasses: CourseRegistrationCourseClass[];
lecturers: LecturerOption[];
highlight?: number; highlight?: number;
filters: { filters: {
status?: string; status?: string;
@ -73,22 +81,17 @@ function courseClassLabel(courseClass: CourseRegistrationCourseClass): string {
return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}`; return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}`;
} }
function lecturerLabel(lecturer: LecturerOption): string {
return `${lecturer.user?.profile?.full_name ?? 'N/A'} - ${lecturer.lecturer_number}`;
}
export default function CourseRegistrationIndex({ export default function CourseRegistrationIndex({
registrations, registrations,
students, students,
academicTerms, academicTerms,
courseClasses, courseClasses,
lecturers,
highlight, highlight,
filters, filters,
}: Props) { }: Props) {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<CourseRegistration | null>(null); const [rejecting, setRejecting] =
const [deleting, setDeleting] = useState<CourseRegistration | null>(null); useState<CourseRegistrationSubmission | null>(null);
const filterFields: FilterField[] = [ const filterFields: FilterField[] = [
{ {
@ -128,19 +131,13 @@ export default function CourseRegistrationIndex({
filters, filters,
}); });
function handleDelete() { function handleApprove(submission: CourseRegistrationSubmission) {
if (!deleting) { router.patch(approve(submission.id));
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
} }
const columns = createCourseRegistrationColumns({ const columns = createCourseRegistrationColumns({
handleEdit: (registration) => setEditing(registration), handleApprove,
handleDeleteClick: (registration) => setDeleting(registration), handleRejectClick: (submission) => setRejecting(submission),
}); });
return ( return (
@ -176,22 +173,6 @@ export default function CourseRegistrationIndex({
students={students} students={students}
academicTerms={academicTerms} academicTerms={academicTerms}
courseClasses={courseClasses} courseClasses={courseClasses}
lecturers={lecturers}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
students={students}
academicTerms={academicTerms}
courseClasses={courseClasses}
lecturers={lecturers}
/> />
<DataTable <DataTable
@ -214,106 +195,162 @@ export default function CourseRegistrationIndex({
} }
/> />
<DeleteConfirmDialog <RejectForm
target={deleting} open={rejecting !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setDeleting(null); setRejecting(null);
} }
}} }}
title="Hapus Registrasi" submission={rejecting}
description={(registration) =>
`Apakah Anda yakin ingin menghapus registrasi KRS untuk "${registration.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/> />
</div> </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({ function RegistrationFields({
errors, errors,
editing,
students, students,
academicTerms, academicTerms,
courseClasses, courseClasses,
lecturers,
}: { }: {
errors: Record<string, string>; errors: Record<string, string>;
editing?: CourseRegistration;
students: CourseRegistrationStudent[]; students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[]; academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[]; courseClasses: CourseRegistrationCourseClass[];
lecturers: LecturerOption[];
}) { }) {
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 ( return (
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Mahasiswa <span className="text-destructive">*</span> Mahasiswa <span className="text-destructive">*</span>
</Label> </Label>
{!editing && <input type="hidden" name="student_id" />} <input
<Select type="hidden"
name="student_id" name="student_id"
defaultValue={ value={student?.id ?? ''}
editing ? String(editing.student_id) : undefined />
} <Combobox
items={students}
value={student}
onValueChange={setStudent}
itemToStringLabel={studentLabel}
isItemEqualToValue={(a, b) => a.id === b.id}
> >
<SelectTrigger className="w-full"> <ComboboxInput
<SelectValue placeholder="Pilih mahasiswa" /> placeholder="Pilih mahasiswa"
</SelectTrigger> className="w-full"
<SelectContent> />
{students.map((student) => ( <ComboboxContent>
<SelectItem <ComboboxEmpty>
key={student.id} Mahasiswa tidak ditemukan.
value={String(student.id)} </ComboboxEmpty>
> <ComboboxList>
{studentLabel(student)} {(option: CourseRegistrationStudent) => (
</SelectItem> <ComboboxItem key={option.id} value={option}>
))} {studentLabel(option)}
</SelectContent> </ComboboxItem>
</Select> )}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.student_id} /> <InputError message={errors.student_id} />
</div> </div>
<div className="grid gap-2">
<Label>
Kelas Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
{!editing && <input type="hidden" name="course_class_id" />}
<Select
name="course_class_id"
defaultValue={
editing ? String(editing.course_class_id) : undefined
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih kelas mata kuliah" />
</SelectTrigger>
<SelectContent>
{courseClasses.map((courseClass) => (
<SelectItem
key={courseClass.id}
value={String(courseClass.id)}
>
{courseClassLabel(courseClass)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.course_class_id} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Periode Akademik <span className="text-destructive">*</span> Periode Akademik <span className="text-destructive">*</span>
</Label> </Label>
{!editing && <input type="hidden" name="academic_term_id" />} <input
<Select type="hidden"
name="academic_term_id" name="academic_term_id"
defaultValue={ value={academicTermId}
editing ? String(editing.academic_term_id) : undefined />
} <Select
value={academicTermId}
onValueChange={setAcademicTermId}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" /> <SelectValue placeholder="Pilih periode akademik" />
@ -329,51 +366,48 @@ function RegistrationFields({
<InputError message={errors.academic_term_id} /> <InputError message={errors.academic_term_id} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Status</Label> <Label>
<input type="hidden" name="status" /> Kelas Mata Kuliah{' '}
<Select <span className="text-destructive">*</span>
name="status" </Label>
defaultValue={editing?.status ?? 'submitted'} {!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"
> >
<SelectTrigger className="w-full"> <Checkbox
<SelectValue placeholder="Pilih status" /> checked={selected.includes(courseClass.id)}
</SelectTrigger> onCheckedChange={(checked) =>
<SelectContent> toggle(courseClass.id, checked === true)
{RegistrationStatuses.map((status) => (
<SelectItem key={status} value={status}>
{RegistrationStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.status} />
</div>
<div className="grid gap-2">
<Label>Disetujui Oleh</Label>
<input type="hidden" name="approved_by" />
<Select
name="approved_by"
defaultValue={
editing?.approved_by
? String(editing.approved_by)
: undefined
} }
> />
<SelectTrigger className="w-full"> <span className="text-sm">
<SelectValue placeholder="Pilih dosen (jika disetujui)" /> {courseClassLabel(courseClass)}
</SelectTrigger> </span>
<SelectContent> </label>
{lecturers.map((lecturer) => (
<SelectItem
key={lecturer.id}
value={String(lecturer.id)}
>
{lecturerLabel(lecturer)}
</SelectItem>
))} ))}
</SelectContent> </div>
</Select> )}
<InputError message={errors.approved_by} /> {selected.map((courseClassId) => (
<input
key={courseClassId}
type="hidden"
name="course_class_ids[]"
value={courseClassId}
/>
))}
<InputError message={errors.course_class_ids} />
</div> </div>
</> </>
); );
@ -385,14 +419,12 @@ function CreateForm({
students, students,
academicTerms, academicTerms,
courseClasses, courseClasses,
lecturers,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
students: CourseRegistrationStudent[]; students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[]; academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[]; courseClasses: CourseRegistrationCourseClass[];
lecturers: LecturerOption[];
}) { }) {
return ( return (
<FormDialog <FormDialog
@ -410,54 +442,9 @@ function CreateForm({
students={students} students={students}
academicTerms={academicTerms} academicTerms={academicTerms}
courseClasses={courseClasses} courseClasses={courseClasses}
lecturers={lecturers}
/> />
</div> </div>
)} )}
</FormDialog> </FormDialog>
); );
} }
function EditForm({
open,
onOpenChange,
editing,
students,
academicTerms,
courseClasses,
lecturers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: CourseRegistration | null;
students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[];
lecturers: LecturerOption[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Registrasi KRS"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<RegistrationFields
errors={errors}
editing={editing}
students={students}
academicTerms={academicTerms}
courseClasses={courseClasses}
lecturers={lecturers}
/>
</div>
)
}
</FormDialog>
);
}

View File

@ -0,0 +1,184 @@
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 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';
type Props = {
submission: CourseRegistrationSubmission;
};
export default function CourseRegistrationShow({ submission }: Props) {
const student = submission.student;
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>
</>
);
}
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

@ -1,3 +1,7 @@
import { Form, Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft, Save } from 'lucide-react';
import { useState } from 'react';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
@ -25,10 +29,6 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { index, store } from '@/routes/admin/users/students'; import { index, store } from '@/routes/admin/users/students';
import { formatDepartmentLabel } from '@/types/department'; import { formatDepartmentLabel } from '@/types/department';
import { Form, Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft, Save } from 'lucide-react';
import { useState } from 'react';
type Department = { id: number; name: string; degree_level: string | null }; type Department = { id: number; name: string; degree_level: string | null };
type Lecturer = { type Lecturer = {
@ -336,7 +336,9 @@ export default function StudentCreate({ departments, lecturers }: Props) {
placeholder="Contoh: 1" placeholder="Contoh: 1"
/> />
<InputError <InputError
message={errors.current_semester} message={
errors.current_semester
}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -376,7 +378,8 @@ export default function StudentCreate({ departments, lecturers }: Props) {
key={lect.id} key={lect.id}
value={lect} value={lect}
> >
{lect.user?.profile {lect.user
?.profile
?.full_name ?? ?.full_name ??
'N/A'}{' '} 'N/A'}{' '}
-{' '} -{' '}
@ -389,7 +392,9 @@ export default function StudentCreate({ departments, lecturers }: Props) {
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
<InputError <InputError
message={errors.academic_advisor_id} message={
errors.academic_advisor_id
}
/> />
</div> </div>
</div> </div>

View File

@ -1,3 +1,7 @@
import { Form, Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft, Save } from 'lucide-react';
import { useState } from 'react';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
@ -25,10 +29,6 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { index, update } from '@/routes/admin/users/students'; import { index, update } from '@/routes/admin/users/students';
import { formatDepartmentLabel } from '@/types/department'; import { formatDepartmentLabel } from '@/types/department';
import { Form, Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft, Save } from 'lucide-react';
import { useState } from 'react';
type Department = { id: number; name: string; degree_level: string | null }; type Department = { id: number; name: string; degree_level: string | null };
type Lecturer = { type Lecturer = {
@ -362,8 +362,8 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
name="enrollment_year" name="enrollment_year"
type="number" type="number"
defaultValue={ defaultValue={
user.student?.enrollment_year ?? user.student
'' ?.enrollment_year ?? ''
} }
/> />
<InputError <InputError
@ -389,7 +389,9 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
} }
/> />
<InputError <InputError
message={errors.current_semester} message={
errors.current_semester
}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -429,7 +431,8 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
key={lect.id} key={lect.id}
value={lect} value={lect}
> >
{lect.user?.profile {lect.user
?.profile
?.full_name ?? ?.full_name ??
'N/A'}{' '} 'N/A'}{' '}
-{' '} -{' '}
@ -442,7 +445,9 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
<InputError <InputError
message={errors.academic_advisor_id} message={
errors.academic_advisor_id
}
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,90 @@
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

@ -0,0 +1,259 @@
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

@ -0,0 +1,133 @@
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}
emptyText="Anda belum pernah mengajukan KRS."
/>
</div>
</>
);
}

View File

@ -0,0 +1,180 @@
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

@ -6,6 +6,7 @@ export type User = {
avatar?: string; avatar?: string;
email_verified_at: string | null; email_verified_at: string | null;
two_factor_enabled?: boolean; two_factor_enabled?: boolean;
roles?: { id: number; name: string }[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
[key: string]: unknown; [key: string]: unknown;

View File

@ -15,31 +15,63 @@ export const RegistrationStatusLabels: Record<RegistrationStatus, string> = {
export type CourseRegistrationStudent = { export type CourseRegistrationStudent = {
id: number; id: number;
student_number: string; student_number: string;
current_semester: number;
department: { id: number; name: string } | null; department: { id: number; name: string } | null;
user: { profile: { full_name: string } | null } | null; user: { profile: { full_name: string } | null } | null;
}; };
export type CourseRegistrationCourseClass = { export type CourseRegistrationCourseClass = {
id: number; id: number;
course: { id: number; code: string; name: string } | null; academic_term_id: number;
}; course: {
export type CourseRegistrationApprover = {
id: number; id: number;
code: string;
name: string;
semester_number: number | null;
department_id: number;
credits?: number;
} | null;
lecturer?: {
user: { profile: { full_name: string } | null } | null; user: { profile: { full_name: string } | null } | null;
} | null;
}; };
export type CourseRegistration = { export type CourseRegistration = {
id: number; id: number;
student_id: number; student_id: number;
student: CourseRegistrationStudent | null;
academic_term_id: number; academic_term_id: number;
academic_term: { id: number; name: string; semester: string } | null;
course_class_id: number; course_class_id: number;
course_class: CourseRegistrationCourseClass | null; course_class: CourseRegistrationCourseClass | null;
status: RegistrationStatus | null;
approved_by: number | null;
approver: CourseRegistrationApprover | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
export type CourseRegistrationActor = {
id: number;
profile: { full_name: string } | null;
};
export type CourseRegistrationSubmissionLog = {
id: number;
status: RegistrationStatus;
reason: string | null;
actor: CourseRegistrationActor | 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;
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[];
logs: CourseRegistrationSubmissionLog[];
};

View File

@ -61,7 +61,13 @@
}); });
Route::prefix('admin/manage')->name('admin.manage.')->group(function () { Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
Route::resource('course-registrations', CourseRegistrationController::class)->except(['create', 'edit', 'show']); Route::resource('course-registrations', CourseRegistrationController::class)->only(['index', 'store']);
Route::prefix('course-registrations/{submission}')->name('course-registrations.')->group(function () {
Route::get('/', [CourseRegistrationController::class, 'show'])->name('show');
Route::patch('approve', [CourseRegistrationController::class, 'approve'])->name('approve');
Route::patch('reject', [CourseRegistrationController::class, 'reject'])->name('reject');
});
Route::resource('course-classes', CourseClassController::class)->except(['create', 'edit', 'show']); Route::resource('course-classes', CourseClassController::class)->except(['create', 'edit', 'show']);
@ -72,6 +78,13 @@
}); });
}); });
Route::middleware('role:mahasiswa')->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/announcements')->name('admin.announcements.')->group(function () { Route::prefix('admin/announcements')->name('admin.announcements.')->group(function () {
Route::get('/', [AnnouncementController::class, 'index'])->name('index'); Route::get('/', [AnnouncementController::class, 'index'])->name('index');
Route::post('/', [AnnouncementController::class, 'store'])->name('store'); Route::post('/', [AnnouncementController::class, 'store'])->name('store');