feat: refactor CourseRegistration management; update controller methods, add new request classes, and adjust routes for department leader role

This commit is contained in:
Yoga Pangestu 2026-09-04 08:29:03 +07:00
parent 80435b6cfa
commit 88319dac15
11 changed files with 115 additions and 95 deletions

View File

@ -2,10 +2,12 @@
namespace App\Http\Controllers\Admin\Manage;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
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\Admin\Manage\CourseRegistration\RejectCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\CourseRegistration\SignAsAdvisorCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\CourseRegistration\SignAsDepartmentLeaderCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\CourseRegistration\UpdateCourseRegistrationRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Course;
use App\Models\CourseRegistrationSubmission;
@ -25,21 +27,18 @@ public function __construct(
public function index(PaginatedRequest $request): Response|RedirectResponse
{
$user = auth()->user();
$user = $request->user();
if ($user->hasRole('mahasiswa')) {
if ($user->hasRole(UserRole::Mahasiswa->value)) {
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(
$user,
...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'),
advisorLecturerId: ! $isKaprodi && $user->hasRole('dosen') ? $user->lecturer?->id : null,
ledDepartmentIds: $isKaprodi ? $user->ledDepartmentIds() : null,
),
'filters' => $request->only(['department_id']),
]);
@ -47,8 +46,8 @@ public function index(PaginatedRequest $request): Response|RedirectResponse
public function show(Student $student): Response
{
$user = auth()->user();
$isStudent = $user->hasRole('mahasiswa');
$user = request()->user();
$isStudent = $user->hasRole(UserRole::Mahasiswa->value);
abort_if($isStudent && $user->student->id !== $student->id, 403);
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
@ -61,7 +60,7 @@ public function show(Student $student): Response
&& $user->can('approve-course-registrations')
&& $user->canReviewCourseRegistrationOf($student);
$canSignAsKaprodi = $user->isKaprodiOf($student);
$canSignAsDepartmentLeader = $user->isDepartmentLeaderOf($student);
$canSignAsAdvisor = $user->isAdvisorOf($student);
$activeTerm = $this->academicTermService->getActive();
@ -97,12 +96,12 @@ public function show(Student $student): Response
'hasActiveTerm' => $activeTerm !== null,
'backHref' => $backHref,
'canReview' => $canReview,
'canSignAsKaprodi' => $canSignAsKaprodi,
'canSignAsDepartmentLeader' => $canSignAsDepartmentLeader,
'canSignAsAdvisor' => $canSignAsAdvisor,
]);
}
public function save(Student $student, string $semester, SaveCourseRegistrationRequest $request): RedirectResponse
public function update(Student $student, string $semester, UpdateCourseRegistrationRequest $request): RedirectResponse
{
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
@ -138,23 +137,19 @@ public function save(Student $student, string $semester, SaveCourseRegistrationR
return to_route('admin.manage.course-registrations.show', $student->id);
}
public function signAsKaprodi(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse
public function signAsDepartmentLeader(Student $student, string $semester, SignAsDepartmentLeaderCourseRegistrationRequest $request): RedirectResponse
{
abort_unless(auth()->user()->isKaprodiOf($student), 403);
$submission = $this->service->findOrCreateForSemester($student, $semester);
$this->service->signAsKaprodi($submission, $request->file('signature'));
$this->service->signAsDepartmentLeader($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
public function signAsAdvisor(Student $student, string $semester, SignAsAdvisorCourseRegistrationRequest $request): RedirectResponse
{
abort_unless(auth()->user()->isAdvisorOf($student), 403);
$submission = $this->service->findOrCreateForSemester($student, $semester);
$this->service->signAsAdvisor($submission, $request->file('signature'));
@ -166,7 +161,11 @@ public function signAsAdvisor(Student $student, string $semester, SignCourseRegi
public function approve(CourseRegistrationSubmission $submission): RedirectResponse
{
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
abort_unless(
request()->user()->can('approve-course-registrations')
&& request()->user()->canReviewCourseRegistrationOf($submission->student),
403
);
$this->service->approve($submission);
@ -175,8 +174,6 @@ 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();

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests\Admin\Manage;
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
use Illuminate\Foundation\Http\FormRequest;
@ -8,7 +8,8 @@ class RejectCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('reject-course-registrations');
return $this->user()->can('reject-course-registrations')
&& $this->user()->canReviewCourseRegistrationOf($this->route('submission')->student);
}
public function rules(): array
@ -17,11 +18,4 @@ public function rules(): array
'reason' => ['required', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'reason.required' => 'Alasan penolakan wajib diisi.',
];
}
}

View File

@ -1,14 +1,14 @@
<?php
namespace App\Http\Requests\Admin\Manage;
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
use Illuminate\Foundation\Http\FormRequest;
class SignCourseRegistrationRequest extends FormRequest
class SignAsAdvisorCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->isAdvisorOf($this->route('student'));
}
public function rules(): array

View File

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

View File

@ -1,16 +1,17 @@
<?php
namespace App\Http\Requests\Admin\Manage;
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest;
class SaveCourseRegistrationRequest extends FormRequest
class UpdateCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
$student = $this->route('student');
return $this->user()->hasRole('mahasiswa')
return $this->user()->hasRole(UserRole::Mahasiswa->value)
&& $this->user()->student?->id === $student?->id;
}

View File

@ -13,7 +13,7 @@
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
#[Appends(['signature_url', 'kaprodi_signature_url', 'advisor_signature_url'])]
#[Appends(['signature_url', 'department_leader_signature_url', 'advisor_signature_url'])]
class CourseRegistrationSubmission extends Model implements HasMedia
{
use InteractsWithMedia;
@ -31,7 +31,7 @@ protected function casts(): array
public function registerMediaCollections(): void
{
$this->addMediaCollection('signature')->singleFile();
$this->addMediaCollection('kaprodi_signature')->singleFile();
$this->addMediaCollection('department_leader_signature')->singleFile();
$this->addMediaCollection('advisor_signature')->singleFile();
}
@ -67,10 +67,10 @@ protected function signatureUrl(): Attribute
);
}
protected function kaprodiSignatureUrl(): Attribute
protected function departmentLeaderSignatureUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('kaprodi_signature') ?: null,
get: fn () => $this->getFirstMediaUrl('department_leader_signature') ?: null,
);
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@ -110,38 +111,38 @@ public function uploads(): HasMany
public function isAdvisorOf(Student $student): bool
{
return $this->hasRole('dosen') && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
}
public function isKaprodiOf(Student $student): bool
public function isDepartmentLeaderOf(Student $student): bool
{
return $this->hasRole('kaprodi') && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
return $this->hasRole(UserRole::Kaprodi->value) && $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.
* Gates page access: the student's academic advisor, the department
* leader 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);
if ($this->hasRole(UserRole::Dosen->value) || $this->hasRole(UserRole::Kaprodi->value)) {
return $this->isAdvisorOf($student) || $this->isDepartmentLeaderOf($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.
* Only the student's academic advisor may approve/reject; the department
* leader signs but does not decide. Other staff roles retain override access.
*/
public function canReviewCourseRegistrationOf(Student $student): bool
{
if ($this->hasRole('dosen')) {
if ($this->hasRole(UserRole::Dosen->value)) {
return $this->isAdvisorOf($student);
}
if ($this->hasRole('kaprodi')) {
if ($this->hasRole(UserRole::Kaprodi->value)) {
return false;
}

View File

@ -4,6 +4,7 @@
use App\Enums\RegistrationStatus;
use App\Enums\StudentStatus;
use App\Enums\UserRole;
use App\Models\AcademicTerm;
use App\Models\ClassEnrollment;
use App\Models\Course;
@ -12,6 +13,7 @@
use App\Models\CourseRegistrationSubmission;
use App\Models\Department;
use App\Models\Student;
use App\Models\User;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
@ -24,22 +26,12 @@ public function __construct(
private readonly AcademicTermService $academicTermService,
) {}
/**
* @return Collection<int, Department>
*/
public function departmentSummary(): Collection
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
{
return Department::query()
->select('id', 'name')
->orderBy('name')
->get();
}
$isDepartmentLeader = $user->hasRole(UserRole::Kaprodi->value);
$advisorLecturerId = ! $isDepartmentLeader && $user->hasRole(UserRole::Dosen->value) ? $user->lecturer?->id : null;
$ledDepartmentIds = $isDepartmentLeader ? $user->ledDepartmentIds() : null;
/**
* @param array<int, int>|null $ledDepartmentIds Restricts results to a kaprodi's own department(s).
*/
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $advisorLecturerId = null, ?array $ledDepartmentIds = null): LengthAwarePaginator
{
$paginator = Student::query()
->select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id'])
->join('departments', 'departments.id', '=', 'students.department_id')
@ -219,11 +211,11 @@ public function saveRegistrations(
});
}
public function signAsKaprodi(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
public function signAsDepartmentLeader(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
{
$this->assertSignable($submission);
$submission->addMedia($signature)->toMediaCollection('kaprodi_signature');
$submission->addMedia($signature)->toMediaCollection('department_leader_signature');
return $submission;
}
@ -246,7 +238,7 @@ private function assertSignable(CourseRegistrationSubmission $submission): void
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->department_leader_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) {
@ -299,4 +291,14 @@ public function reject(CourseRegistrationSubmission $submission, string $reason)
return $submission;
});
}
/**
* @return Collection<int, Department>
*/
public function departmentSummary(): Collection
{
return Department::query()
->orderBy('name')
->get(['id', 'name']);
}
}

View File

@ -25,9 +25,9 @@ import { Textarea } from '@/components/ui/textarea';
import {
advisor_signature,
approve,
kaprodi_signature,
department_leader_signature,
reject,
save,
update,
} from '@/routes/admin/manage/course-registrations';
import type {
CourseRegistrationSubmission,
@ -238,7 +238,7 @@ function SemesterCard({
isSemesterOpen,
backHref,
canReview,
canSignAsKaprodi,
canSignAsDepartmentLeader,
canSignAsAdvisor,
}: {
semester: number | null;
@ -247,14 +247,14 @@ function SemesterCard({
isSemesterOpen: boolean;
backHref: string | null;
canReview: boolean;
canSignAsKaprodi: boolean;
canSignAsDepartmentLeader: 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 departmentLeader = department?.current_leader?.lecturer ?? null;
const advisor = student?.academic_advisor ?? null;
const isViewOnly = backHref !== null;
@ -267,11 +267,13 @@ function SemesterCard({
canReview && submission.status === 'submitted' && !notYetSubmitted;
const canReviewersSign =
submission.status === 'submitted' && !notYetSubmitted;
const missingKaprodiSignature = !submission.kaprodi_signature_url;
const missingDepartmentLeaderSignature =
!submission.department_leader_signature_url;
const missingOwnSignature =
(canSignAsKaprodi && !submission.kaprodi_signature_url) ||
(canSignAsDepartmentLeader &&
!submission.department_leader_signature_url) ||
(canSignAsAdvisor && !submission.advisor_signature_url);
const cannotApprove = missingKaprodiSignature || missingOwnSignature;
const cannotApprove = missingDepartmentLeaderSignature || missingOwnSignature;
const totalCredits = submission.course_registrations.reduce(
(sum, registration) =>
@ -291,7 +293,7 @@ function SemesterCard({
return (
<Form
action={save([studentId, semesterKey(semester)])}
action={update([studentId, semesterKey(semester)])}
options={{ preserveScroll: true }}
className="flex flex-col gap-6"
>
@ -471,25 +473,28 @@ function SemesterCard({
<p>Ketua Program Studi,</p>
<ReviewerSignatureField
signatureUrl={
submission.kaprodi_signature_url
submission.department_leader_signature_url
}
alt="Tanda tangan Ketua Program Studi"
canSign={
canSignAsKaprodi &&
canSignAsDepartmentLeader &&
canReviewersSign
}
submitUrl={kaprodi_signature.url([
studentId,
semesterKey(semester),
])}
submitUrl={department_leader_signature.url(
[
studentId,
semesterKey(semester),
],
)}
/>
<p className="font-semibold">
{kaprodi?.user?.profile
{departmentLeader?.user?.profile
?.full_name ?? '-'}
</p>
<p className="text-xs text-muted-foreground">
NIDN.{' '}
{kaprodi?.lecturer_number ?? '-'}
{departmentLeader?.lecturer_number ??
'-'}
</p>
</div>
@ -579,7 +584,7 @@ function SemesterCard({
</div>
{cannotApprove && (
<p className="text-xs text-muted-foreground">
{missingKaprodiSignature
{missingDepartmentLeaderSignature
? 'Menunggu tanda tangan Ketua Program Studi sebelum dapat disetujui.'
: 'Silakan tanda tangani KRS ini terlebih dahulu sebelum menyetujui.'}
</p>
@ -628,7 +633,7 @@ type Props = {
hasActiveTerm: boolean;
backHref: string | null;
canReview: boolean;
canSignAsKaprodi: boolean;
canSignAsDepartmentLeader: boolean;
canSignAsAdvisor: boolean;
};
@ -640,7 +645,7 @@ export default function CourseRegistrationDetail({
hasActiveTerm,
backHref,
canReview,
canSignAsKaprodi,
canSignAsDepartmentLeader,
canSignAsAdvisor,
}: Props) {
const [activeSemester, setActiveSemester] = useState(() =>
@ -712,7 +717,7 @@ export default function CourseRegistrationDetail({
)}
backHref={backHref}
canReview={canReview}
canSignAsKaprodi={canSignAsKaprodi}
canSignAsDepartmentLeader={canSignAsDepartmentLeader}
canSignAsAdvisor={canSignAsAdvisor}
/>
) : (

View File

@ -78,7 +78,7 @@ export type CourseRegistrationSubmission = {
rejection_reason: string | null;
signed_at: string;
signature_url: string | null;
kaprodi_signature_url: string | null;
department_leader_signature_url: string | null;
advisor_signature_url: string | null;
has_registrations: boolean;
student: CourseRegistrationSubmissionStudent | null;

View File

@ -107,8 +107,8 @@
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::put('{student}/{semester}', [CourseRegistrationController::class, 'update'])->name('update')->middleware('permission:view-course-registrations');
Route::patch('{student}/{semester}/department-leader-signature', [CourseRegistrationController::class, 'signAsDepartmentLeader'])->name('department_leader_signature')->middleware('permission:view-course-registrations');
Route::patch('{student}/{semester}/advisor-signature', [CourseRegistrationController::class, 'signAsAdvisor'])->name('advisor_signature')->middleware('permission:approve-course-registrations');
});