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:
parent
6621757713
commit
692b84cc1b
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' => [
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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} ·{' '}
|
||||
{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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} ·{' '}
|
||||
{student?.department?.name ?? '-'} ·
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
@ -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} ·{' '}
|
||||
{student.department?.name ?? '-'} · 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
|
||||
·{' '}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 ·{' '}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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'])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user