Compare commits

..

No commits in common. "8528a3258d8effe211bda84cfa34161cac00b251" and "93ddee8fd6f47b2e2b6331c558f47785521e75e2" have entirely different histories.

66 changed files with 2327 additions and 2617 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\ClassEnrollmentRequest;
use App\Models\ClassEnrollment;
use App\Models\CourseClass;
use App\Services\Admin\Manage\ClassEnrollmentService;
@ -23,9 +24,17 @@ public function index(CourseClass $courseClass): Response
return Inertia::render('admin/manage/course-classes/enrollments', [
'courseClass' => $courseClass,
'enrollments' => $this->service->forClass($courseClass),
'availableStudents' => $this->service->availableStudents($courseClass),
]);
}
public function store(ClassEnrollmentRequest $request, CourseClass $courseClass): RedirectResponse
{
$this->service->enrollMany($courseClass, $request->validated('student_ids'));
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil ditambahkan ke kelas.'])->back();
}
public function destroy(CourseClass $courseClass, ClassEnrollment $enrollment): RedirectResponse
{
$this->service->unenroll($enrollment);

View File

@ -4,7 +4,6 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CourseClassRequest;
use App\Http\Requests\Admin\Manage\DuplicateCourseClassesRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\CourseClass;
use App\Services\Admin\Manage\CourseClassService;
@ -35,21 +34,15 @@ public function index(PaginatedRequest $request): Response
'courses' => $this->courseService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClassAssignments' => $this->service->getAllForSelect(),
'filters' => $request->only(['academic_term_id', 'method']),
]);
}
public function store(CourseClassRequest $request): RedirectResponse
{
$created = $this->service->createMany($request->validated());
$this->service->create($request->validated());
Inertia::flash('toast', [
'type' => 'success',
'message' => $created->count() > 1
? "{$created->count()} kelas mata kuliah berhasil ditambahkan."
: 'Kelas mata kuliah berhasil ditambahkan.',
]);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil ditambahkan.']);
return to_route('admin.manage.course-classes.index');
}
@ -69,24 +62,4 @@ public function destroy(CourseClass $courseClass): RedirectResponse
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil dihapus.'])->back();
}
public function duplicate(DuplicateCourseClassesRequest $request): RedirectResponse
{
$result = $this->service->duplicateFromTerm(
$request->validated('source_academic_term_id'),
$request->validated('target_academic_term_id'),
);
$message = $result['created'] > 0
? "{$result['created']} kelas mata kuliah berhasil diduplikasi."
: 'Tidak ada kelas mata kuliah baru yang diduplikasi.';
if ($result['skipped'] > 0) {
$message .= " {$result['skipped']} dilewati karena sudah ada di periode tujuan.";
}
Inertia::flash('toast', ['type' => 'success', 'message' => $message]);
return to_route('admin.manage.course-classes.index');
}
}

View File

@ -3,16 +3,17 @@
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\Course;
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;
@ -20,154 +21,109 @@ 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|RedirectResponse
public function index(PaginatedRequest $request): Response
{
$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(),
departmentId: $request->validated('department_id'),
advisorLecturerId: ! $isKaprodi && $user->hasRole('dosen') ? $user->lecturer?->id : null,
ledDepartmentIds: $isKaprodi ? $user->ledDepartmentIds() : null,
status: $request->validated('status'),
academicTermId: $request->validated('academic_term_id'),
),
'filters' => $request->only(['department_id']),
'students' => $this->studentService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClasses' => $this->courseClassService->getAllForSelect(),
'filters' => $request->only(['status', 'academic_term_id']),
]);
}
public function show(Student $student): Response
public function mine(Request $request): Response
{
$user = auth()->user();
$isStudent = $user->hasRole('mahasiswa');
$student = $this->currentStudent($request);
abort_if($isStudent && $user->student->id !== $student->id, 403);
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
$term = $this->academicTermService->getActive();
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
$activeSubmission = $term ? $this->service->currentSubmission($student, $term) : null;
$backHref = $isStudent
? null
: route('admin.manage.course-registrations.index');
return Inertia::render('student/course-registrations/index', [
'academicTerm' => $term,
'payment' => $payment,
'canSubmit' => $this->service->canSubmit($activeSubmission),
'activeSubmission' => $activeSubmission,
'submissions' => $this->service->submissionsFor($student),
]);
}
$canReview = ! $isStudent
&& $user->can('approve-course-registrations')
&& $user->canReviewCourseRegistrationOf($student);
public function create(Request $request): Response|RedirectResponse
{
$student = $this->currentStudent($request);
$canSignAsKaprodi = $user->isKaprodiOf($student);
$canSignAsAdvisor = $user->isAdvisorOf($student);
$student->load(['user.profile', 'department', 'academicAdvisor.user.profile']);
$activeTerm = $this->academicTermService->getActive();
$allCourseClasses = $activeTerm
? $this->service->availableCourseClasses($student->department_id, $activeTerm->id)
: collect();
$term = $this->academicTermService->getActive();
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
$submission = $term ? $this->service->currentSubmission($student, $term) : null;
$departmentCourses = $this->service->departmentCourses($student->department_id);
if (! $term || ! $payment['is_paid'] || ! $this->service->canSubmit($submission)) {
return to_route('student.course-registrations.index');
}
$semesters = $departmentCourses
->map(fn (Course $course) => $course->semester_number)
->unique()
->sort()
->values();
return Inertia::render('student/course-registrations/create', [
'student' => $student,
'academicTerm' => $term,
'submission' => $submission,
'availableCourses' => $this->service->availableCourseClasses($student, $term),
]);
}
$submissions = $activeTerm
? $semesters->mapWithKeys(fn (?int $semesterNumber) => [
CourseRegistrationSubmission::semesterKey($semesterNumber) => $this->service->buildSubmissionPayload(
$student,
$activeTerm,
$semesterNumber,
$departmentCourses,
$allCourseClasses,
),
])
: collect();
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', [
'submissions' => $submissions,
'semesters' => $semesters->values(),
'openSemesters' => $activeTerm?->open_semesters ?? [],
'studentCurrentSemester' => $student->current_semester,
'hasActiveTerm' => $activeTerm !== null,
'backHref' => $backHref,
'canReview' => $canReview,
'canSignAsKaprodi' => $canSignAsKaprodi,
'canSignAsAdvisor' => $canSignAsAdvisor,
'submission' => $this->service->withDetails($submission),
]);
}
public function save(Student $student, string $semester, SaveCourseRegistrationRequest $request): RedirectResponse
public function store(CourseRegistrationRequest $request): RedirectResponse
{
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
if ($request->routeIs('student.*')) {
$student = $this->currentStudent($request);
$term = $this->academicTermService->getActive();
$activeTerm = $this->academicTermService->getActive();
$submission = $this->service->sign(
$student,
$term,
$request->validated('course_class_ids'),
$request->file('signature'),
);
abort_if($activeTerm === null, 422);
Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditandatangani.']);
$selectableIds = $this->service->selectableCourseClassIds(
$student->department_id,
$activeTerm->id,
$semesterNumber,
$activeTerm->open_semesters ?? [],
$student->current_semester,
);
return to_route('student.course-registrations.show', $submission);
}
$selectedIds = array_values(array_intersect(
$request->validated('course_class_ids') ?? [],
$selectableIds,
));
$this->service->create($request->validated());
$submission = $this->service->findOrCreateForStudent($student, $activeTerm, $semesterNumber);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']);
$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);
return to_route('admin.manage.course-registrations.index');
}
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();
@ -175,10 +131,17 @@ public function approve(CourseRegistrationSubmission $submission): RedirectRespo
public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
{
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
$this->service->reject($submission, $request->validated('reason'));
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
}
private function currentStudent(Request $request): Student
{
$student = $request->user()->student;
abort_if(! $student, 403);
return $student;
}
}

View File

@ -7,7 +7,6 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Department;
use App\Services\Admin\Master\DepartmentService;
use App\Services\Admin\Users\LecturerService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -16,14 +15,12 @@ class DepartmentController extends Controller
{
public function __construct(
private readonly DepartmentService $service,
private readonly LecturerService $lecturerService,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/master/departments/index', [
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
'lecturers' => $this->lecturerService->getAllForSelect(),
]);
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ClassEnrollmentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create-course-class-enrollments');
}
public function rules(): array
{
$courseClass = $this->route('course_class');
$departmentId = $courseClass->course->department_id;
return [
'student_ids' => ['required', 'array', 'min:1'],
'student_ids.*' => [
'integer',
Rule::exists('students', 'id')->where('department_id', $departmentId),
Rule::unique('class_enrollments', 'student_id')->where('course_class_id', $courseClass->id),
],
];
}
}

View File

@ -3,10 +3,7 @@
namespace App\Http\Requests\Admin\Manage;
use App\Enums\ClassMethod;
use App\Enums\Semester;
use App\Models\AcademicTerm;
use App\Models\Course;
use App\Models\Lecturer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -19,73 +16,10 @@ public function authorize(): bool
public function rules(): array
{
$academicTermId = $this->input('academic_term_id');
$academicTerm = $academicTermId ? AcademicTerm::find($academicTermId) : null;
$semesterParityRule = function ($attribute, $value, $fail) use ($academicTerm) {
if (! $academicTerm) {
return;
}
$semesterNumber = Course::find($value)?->semester_number;
if ($semesterNumber === null) {
return;
}
$isOddSemester = $semesterNumber % 2 === 1;
$matches = $academicTerm->semester === Semester::odd ? $isOddSemester : ! $isOddSemester;
if (! $matches) {
$fail('Mata kuliah semester '.$semesterNumber.' tidak sesuai dengan periode '.$academicTerm->semester->label().'.');
}
};
if ($this->isMethod('post')) {
return [
'course_ids' => ['required', 'array', 'min:1'],
'course_ids.*' => [
'integer',
Rule::exists('courses', 'id'),
Rule::unique('course_classes', 'course_id')
->where('academic_term_id', $academicTermId)
->whereNull('deleted_at'),
$semesterParityRule,
],
'lecturer_id' => [
'required',
'integer',
function ($attribute, $value, $fail) {
$courseIds = (array) $this->input('course_ids', []);
$departmentIds = Course::whereIn('id', $courseIds)->pluck('department_id')->unique();
$lecturer = Lecturer::find($value);
$coveredDepartmentIds = $lecturer
? $lecturer->departments()->pluck('departments.id')
: collect();
if ($departmentIds->diff($coveredDepartmentIds)->isNotEmpty()) {
$fail('Dosen tidak terdaftar di jurusan seluruh mata kuliah yang dipilih.');
}
},
],
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())],
];
}
$departmentId = Course::find($this->input('course_id'))?->department_id;
return [
'course_id' => [
'required',
'integer',
Rule::exists('courses', 'id'),
Rule::unique('course_classes', 'course_id')
->where('academic_term_id', $academicTermId)
->whereNull('deleted_at')
->ignore($this->route('course_class')?->id),
$semesterParityRule,
],
'course_id' => ['required', 'integer', Rule::exists('courses', 'id')],
'lecturer_id' => [
'required',
'integer',

View File

@ -0,0 +1,75 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Services\Admin\Manage\CourseRegistrationService;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->routeIs('student.*')) {
return $this->user()->can('create-course-registrations');
}
$student = $this->user()->student;
if (! $student) {
return false;
}
$term = app(AcademicTermService::class)->getActive();
if (! $term) {
return false;
}
$service = app(CourseRegistrationService::class);
$submission = $service->currentSubmission($student, $term);
if (! $service->canSubmit($submission)) {
return false;
}
return $service->paymentStatus($student, $term)['is_paid'];
}
public function rules(): array
{
if ($this->routeIs('student.*')) {
$student = $this->user()->student;
$term = app(AcademicTermService::class)->getActive();
$allowedIds = $student && $term
? app(CourseRegistrationService::class)->allowedCourseClassIds($student, $term)
: [];
return [
'course_class_ids' => ['required', 'array', 'min:1'],
'course_class_ids.*' => ['integer', Rule::in($allowedIds)],
'signature' => ['required', 'image', 'max:2048'],
];
}
return [
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'course_class_ids' => ['required', 'array', 'min:1'],
'course_class_ids.*' => [
'integer',
Rule::exists('course_classes', 'id'),
Rule::unique('course_registrations', 'course_class_id')
->where('student_id', $this->input('student_id')),
],
];
}
public function attributes(): array
{
return [
'course_class_ids' => 'mata kuliah',
];
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Models\AcademicTerm;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class DuplicateCourseClassesRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create-course-classes');
}
public function rules(): array
{
return [
'source_academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'target_academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
if ($this->input('source_academic_term_id') === $this->input('target_academic_term_id')) {
$validator->errors()->add('target_academic_term_id', 'Periode tujuan harus berbeda dari periode sumber.');
return;
}
$sourceTerm = AcademicTerm::find($this->input('source_academic_term_id'));
$targetTerm = AcademicTerm::find($this->input('target_academic_term_id'));
if ($sourceTerm && $targetTerm && $sourceTerm->semester !== $targetTerm->semester) {
$validator->errors()->add('target_academic_term_id', 'Periode tujuan harus semester yang sama dengan periode sumber (Ganjil ke Ganjil, Genap ke Genap).');
}
});
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
class SaveCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
$student = $this->route('student');
return $this->user()->hasRole('mahasiswa')
&& $this->user()->student?->id === $student?->id;
}
public function rules(): array
{
return [
'course_class_ids' => ['array'],
'course_class_ids.*' => ['integer'],
'signature' => ['nullable', 'file', 'image', 'max:2048'],
];
}
}

View File

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

View File

@ -16,13 +16,13 @@ public function authorize(): bool
public function rules(): array
{
return [
'academic_year' => [
'name' => [
'required',
'string',
'max:20',
function ($attribute, $value, $fail) {
if (! preg_match('/^(\d{4})\/(\d{4})$/', $value, $matches)) {
$fail('Format tahun ajaran harus YYYY/YYYY, contoh: 2025/2026.');
$fail('Format name harus YYYY/YYYY, contoh: 2025/2026.');
return;
}
@ -40,21 +40,45 @@ function ($attribute, $value, $fail) {
'string',
Rule::in(Semester::values()),
Rule::unique('academic_terms')->where(function ($query) {
return $query->where('academic_year', $this->input('academic_year'));
return $query->where('name', $this->input('name'));
})->ignore($this->route('academic_term')),
],
'start_date' => [
'required',
'date',
function ($attribute, $value, $fail) {
$name = $this->input('name');
$semester = $this->input('semester');
if ($name && preg_match('/^(\d{4})\/(\d{4})$/', $name, $matches)) {
$startYear = (int) $matches[1];
$endYear = (int) $matches[2];
$dateYear = (int) date('Y', strtotime($value));
$expectedYear = $semester === 'even' ? $endYear : $startYear;
if ($dateYear !== $expectedYear) {
$fail('Start date harus berada di tahun '.$expectedYear.' untuk semester '.($semester === 'even' ? 'Genap' : 'Ganjil').'.');
}
}
},
],
'end_date' => [
'required',
'date',
'after_or_equal:start_date',
function ($attribute, $value, $fail) {
$name = $this->input('name');
$semester = $this->input('semester');
if ($name && preg_match('/^(\d{4})\/(\d{4})$/', $name, $matches)) {
$startYear = (int) $matches[1];
$endYear = (int) $matches[2];
$dateYear = (int) date('Y', strtotime($value));
$expectedYear = $semester === 'even' ? $endYear : $startYear;
if ($dateYear !== $expectedYear) {
$fail('End date harus berada di tahun '.$expectedYear.' untuk semester '.($semester === 'even' ? 'Genap' : 'Ganjil').'.');
}
}
},
],
'is_active' => ['boolean'],
'open_semesters' => ['nullable', 'array'],
'open_semesters.*' => ['integer', 'between:1,8'],
];
}
}

View File

@ -23,14 +23,6 @@ public function rules(): array
],
'name' => ['required', 'string', 'max:100'],
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])],
'lecturer_id' => [
'nullable',
Rule::requiredIf($this->route('department') !== null),
'integer',
Rule::exists('lecturer_department', 'lecturer_id')
->where('department_id', $this->route('department')?->id ?? 0),
],
'leadership_started_at' => ['nullable', 'date', 'required_with:lecturer_id'],
];
}
}

View File

@ -73,8 +73,7 @@ public function rules(): array
'academic_advisor_id' => [
'required',
'integer',
Rule::exists('lecturer_department', 'lecturer_id')
->where('department_id', $this->input('department_id')),
Rule::exists('lecturers', 'id'),
],
'status' => [
'nullable',

View File

@ -28,7 +28,6 @@ protected function casts(): array
'start_date' => 'date',
'end_date' => 'date',
'is_active' => 'boolean',
'open_semesters' => 'array',
];
}

View File

@ -13,7 +13,7 @@
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
#[Appends(['signature_url', 'kaprodi_signature_url', 'advisor_signature_url'])]
#[Appends(['signature_url'])]
class CourseRegistrationSubmission extends Model implements HasMedia
{
use InteractsWithMedia;
@ -22,7 +22,6 @@ protected function casts(): array
{
return [
'status' => RegistrationStatus::class,
'semester_number' => 'integer',
'signed_at' => 'datetime',
'reviewed_at' => 'datetime',
];
@ -31,8 +30,6 @@ 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
@ -66,28 +63,4 @@ protected function signatureUrl(): Attribute
get: fn () => $this->getFirstMediaUrl('signature') ?: null,
);
}
protected function kaprodiSignatureUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('kaprodi_signature') ?: null,
);
}
protected function advisorSignatureUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('advisor_signature') ?: null,
);
}
public static function semesterKey(?int $semesterNumber): string
{
return $semesterNumber === null ? 'lainnya' : (string) $semesterNumber;
}
public static function parseSemesterKey(string $semester): ?int
{
return $semester === 'lainnya' ? null : (int) $semester;
}
}

View File

@ -62,54 +62,4 @@ public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class);
}
public function isAdvisorOf(Student $student): bool
{
return $this->hasRole('dosen') && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
}
public function isKaprodiOf(Student $student): bool
{
return $this->hasRole('kaprodi') && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
}
/**
* Gates page access: the student's academic advisor, the kaprodi of
* their department, or staff roles may view a student's KRS.
*/
public function canAccessCourseRegistrationOf(Student $student): bool
{
if ($this->hasRole('dosen') || $this->hasRole('kaprodi')) {
return $this->isAdvisorOf($student) || $this->isKaprodiOf($student);
}
return true;
}
/**
* Only the student's academic advisor may approve/reject; the kaprodi
* signs but does not decide. Other staff roles retain override access.
*/
public function canReviewCourseRegistrationOf(Student $student): bool
{
if ($this->hasRole('dosen')) {
return $this->isAdvisorOf($student);
}
if ($this->hasRole('kaprodi')) {
return false;
}
return true;
}
/**
* @return array<int, int>
*/
public function ledDepartmentIds(): array
{
return $this->lecturer
? $this->lecturer->leaderships()->whereNull('ended_at')->pluck('department_id')->all()
: [];
}
}

View File

@ -33,7 +33,7 @@ public function paginated(
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
->withSum('payments as paid_total', 'amount_paid')
->withCount('payments')
->with(['student.user.profile', 'student.department', 'academicTerm:id,academic_year,semester,start_date,end_date'])
->with(['student.user.profile', 'student.department', 'academicTerm:id,name,semester,start_date,end_date'])
->when($status, fn ($q) => match ($status) {
'paid' => $q->havingRaw('COALESCE(paid_total, 0) >= amount_due'),
'partial' => $q->havingRaw('COALESCE(paid_total, 0) > 0 AND COALESCE(paid_total, 0) < amount_due'),

View File

@ -4,6 +4,7 @@
use App\Models\ClassEnrollment;
use App\Models\CourseClass;
use App\Models\Student;
use Illuminate\Database\Eloquent\Collection;
class ClassEnrollmentService
@ -16,6 +17,25 @@ public function forClass(CourseClass $courseClass): Collection
->get();
}
public function availableStudents(CourseClass $courseClass): Collection
{
return Student::query()
->where('department_id', $courseClass->course->department_id)
->whereDoesntHave('enrollments', fn ($q) => $q->where('course_class_id', $courseClass->id))
->with(['user.profile', 'department'])
->get();
}
public function enrollMany(CourseClass $courseClass, array $studentIds): void
{
foreach ($studentIds as $studentId) {
$courseClass->enrollments()->firstOrCreate(
['student_id' => $studentId],
['enrolled_at' => now()],
);
}
}
public function unenroll(ClassEnrollment $enrollment): bool
{
return $enrollment->delete();

View File

@ -5,7 +5,6 @@
use App\Models\CourseClass;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class CourseClassService
{
@ -19,32 +18,19 @@ public function getAllForSelect(): Collection
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
{
return CourseClass::query()
->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id', 'course_classes.method'])
->join('academic_terms', 'academic_terms.id', '=', 'course_classes.academic_term_id')
->join('courses', 'courses.id', '=', 'course_classes.course_id')
->select(['id', 'course_id', 'lecturer_id', 'academic_term_id', 'method'])
->withCount('enrollments')
->with(['course:id,code,name,department_id,semester_number', 'lecturer.user.profile', 'academicTerm:id,academic_year,semester,start_date,end_date'])
->with(['course:id,code,name,department_id', 'lecturer.user.profile', 'academicTerm:id,name,semester,start_date,end_date'])
->when($search, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")))
->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
->when($method, fn ($q) => $q->where('course_classes.method', $method))
->orderByDesc('academic_terms.start_date')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
->when($method, fn ($q) => $q->where('method', $method))
->latest()
->paginate($perPage);
}
/**
* @param array{course_ids: array<int, int>, lecturer_id: int, academic_term_id: int, method?: string|null} $data
* @return Collection<int, CourseClass>
*/
public function createMany(array $data): Collection
public function create(array $data): CourseClass
{
return new Collection(array_map(fn (int $courseId) => CourseClass::create([
'course_id' => $courseId,
'lecturer_id' => $data['lecturer_id'],
'academic_term_id' => $data['academic_term_id'],
'method' => $data['method'] ?? null,
]), $data['course_ids']));
return CourseClass::create($data);
}
public function update(CourseClass $courseClass, array $data): CourseClass
@ -62,47 +48,4 @@ public function delete(CourseClass $courseClass): bool
{
return $courseClass->delete();
}
/**
* Copies every course class from one academic term into another,
* skipping courses that already have a class in the target term.
*
* @return array{created: int, skipped: int}
*/
public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademicTermId): array
{
return DB::transaction(function () use ($sourceAcademicTermId, $targetAcademicTermId) {
$sourceClasses = CourseClass::query()
->where('academic_term_id', $sourceAcademicTermId)
->get(['course_id', 'lecturer_id', 'method']);
$existingCourseIds = CourseClass::query()
->where('academic_term_id', $targetAcademicTermId)
->pluck('course_id')
->all();
$created = 0;
$skipped = 0;
foreach ($sourceClasses as $sourceClass) {
if (in_array($sourceClass->course_id, $existingCourseIds, true)) {
$skipped++;
continue;
}
CourseClass::create([
'course_id' => $sourceClass->course_id,
'lecturer_id' => $sourceClass->lecturer_id,
'academic_term_id' => $targetAcademicTermId,
'method' => $sourceClass->method,
]);
$existingCourseIds[] = $sourceClass->course_id;
$created++;
}
return ['created' => $created, 'skipped' => $skipped];
});
}
}

View File

@ -3,16 +3,14 @@
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\Services\Admin\Master\AcademicTermService;
use App\Models\TuitionInvoice;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
@ -20,231 +18,70 @@
class CourseRegistrationService
{
public function __construct(
private readonly AcademicTermService $academicTermService,
) {}
/**
* @return Collection<int, Department>
*/
public function departmentSummary(): Collection
public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator
{
return Department::query()
->select('id', 'name')
->orderBy('name')
->get();
}
/**
* @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(['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')
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 $paginator->through(fn (Student $student) => [
'student_id' => $student->id,
'student' => $student,
]);
}
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 $departmentCourses, Collection $allCourseClasses): array
{
$submission = $this->findOrCreateForStudent($student, $activeTerm, $semesterNumber);
$data = $this->withDetails($submission);
$payload = $data->toArray();
$registeredCourseClassIds = $data->courseRegistrations->pluck('course_class_id')->all();
$courseClassesByCourseId = $allCourseClasses->keyBy('course_id');
$payload['has_registrations'] = ! empty($registeredCourseClassIds);
$payload['course_registrations'] = $departmentCourses
->filter(fn (Course $course) => $course->semester_number === $semesterNumber)
->map(function (Course $course) use ($courseClassesByCourseId, $registeredCourseClassIds) {
$courseClass = $courseClassesByCourseId->get($course->id);
return [
'id' => $courseClass?->id ?? -$course->id,
'course' => $course,
'course_class' => $courseClass ? ['id' => $courseClass->id] : null,
'is_registered' => $courseClass !== null && in_array($courseClass->id, $registeredCourseClassIds, true),
];
})
->values();
return $payload;
}
/**
* The department's full course curriculum, independent of which classes
* have been opened for the current academic term.
* Admin-created registration: additively attach course classes to a
* student's submission for a term, without disturbing an existing
* submission's review status.
*
* @return Collection<int, Course>
* @param array{student_id: int, academic_term_id: int, course_class_ids: array<int, int>} $data
*/
public function departmentCourses(int $departmentId): Collection
public function create(array $data): CourseRegistrationSubmission
{
return Course::query()
->where('department_id', $departmentId)
->orderBy('semester_number')
->orderBy('name')
->get(['id', 'code', 'name', 'credits', 'semester_number']);
}
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(),
],
);
public function withDetails(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
{
return $submission->load([
'student.user.profile',
'student.department.currentLeader.lecturer.user.profile',
'student.academicAdvisor.user.profile',
'academicTerm:id,academic_year,semester,start_date,end_date',
'reviewer.profile',
'courseRegistrations.courseClass.course',
'logs' => fn ($q) => $q->oldest(),
'logs.actor.profile',
]);
}
if ($submission->wasRecentlyCreated) {
$submission->logs()->create([
'status' => RegistrationStatus::Submitted,
'actor_id' => auth()->id(),
]);
}
/**
* @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, ?int $studentCurrentSemester): array
{
if ($semesterNumber === null || $semesterNumber !== $studentCurrentSemester || ! 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) {
foreach ($data['course_class_ids'] as $courseClassId) {
CourseRegistration::create([
'student_id' => $studentId,
'academic_term_id' => $academicTermId,
'student_id' => $data['student_id'],
'academic_term_id' => $data['academic_term_id'],
'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,
@ -276,8 +113,6 @@ 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,
@ -295,4 +130,164 @@ public function reject(CourseRegistrationSubmission $submission, string $reason)
return $submission;
});
}
/**
* @return Collection<int, array{course: Course, course_class: ?CourseClass}>
*/
public function availableCourseClasses(Student $student, AcademicTerm $term): Collection
{
$courses = Course::query()
->where('department_id', $student->department_id)
->where('semester_number', $student->current_semester)
->orderBy('name')
->get(['id', 'code', 'name', 'credits']);
$classes = CourseClass::query()
->whereIn('course_id', $courses->pluck('id'))
->where('academic_term_id', $term->id)
->with('lecturer.user.profile')
->get()
->keyBy('course_id');
return $courses->map(fn (Course $course) => [
'course' => $course,
'course_class' => $classes->get($course->id),
])->values();
}
/**
* @return array{has_invoice: bool, is_paid: bool, amount_due: ?float, paid_total: ?float}
*/
public function paymentStatus(Student $student, AcademicTerm $term): array
{
$invoice = TuitionInvoice::query()
->where('student_id', $student->id)
->where('academic_term_id', $term->id)
->withSum('payments as paid_total', 'amount_paid')
->first();
if (! $invoice) {
return [
'has_invoice' => false,
'is_paid' => false,
'amount_due' => null,
'paid_total' => null,
];
}
$paidTotal = (float) ($invoice->paid_total ?? 0);
$amountDue = (float) $invoice->amount_due;
return [
'has_invoice' => true,
'is_paid' => $paidTotal >= $amountDue,
'amount_due' => $amountDue,
'paid_total' => $paidTotal,
];
}
public function currentSubmission(Student $student, AcademicTerm $term): ?CourseRegistrationSubmission
{
return CourseRegistrationSubmission::query()
->where('student_id', $student->id)
->where('academic_term_id', $term->id)
->with([
'courseRegistrations.courseClass.course',
'courseRegistrations.courseClass.lecturer.user.profile',
'logs' => fn ($q) => $q->latest(),
'logs.actor.profile',
])
->first();
}
/**
* @return Collection<int, CourseRegistrationSubmission>
*/
public function submissionsFor(Student $student): Collection
{
return CourseRegistrationSubmission::query()
->where('student_id', $student->id)
->withCount('courseRegistrations')
->with('academicTerm:id,name,semester,start_date,end_date')
->latest()
->get();
}
public function withDetails(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
{
return $submission->load([
'student.user.profile',
'student.department',
'academicTerm:id,name,semester,start_date,end_date',
'reviewer.profile',
'courseRegistrations.courseClass.course',
'courseRegistrations.courseClass.lecturer.user.profile',
'logs' => fn ($q) => $q->oldest(),
'logs.actor.profile',
]);
}
public function canSubmit(?CourseRegistrationSubmission $submission): bool
{
return $submission === null || $submission->status === RegistrationStatus::Rejected;
}
/**
* @return array<int, int>
*/
public function allowedCourseClassIds(Student $student, AcademicTerm $term): array
{
$courseIds = Course::query()
->where('department_id', $student->department_id)
->where('semester_number', $student->current_semester)
->pluck('id');
return CourseClass::query()
->whereIn('course_id', $courseIds)
->where('academic_term_id', $term->id)
->pluck('id')
->all();
}
/**
* Student self-service sign-off: replaces the submission's course
* selection wholesale and attaches the captured signature.
*
* @param array<int, int> $courseClassIds
*/
public function sign(Student $student, AcademicTerm $term, array $courseClassIds, UploadedFile $signatureFile): CourseRegistrationSubmission
{
return DB::transaction(function () use ($student, $term, $courseClassIds, $signatureFile) {
$submission = CourseRegistrationSubmission::updateOrCreate(
['student_id' => $student->id, 'academic_term_id' => $term->id],
[
'status' => RegistrationStatus::Submitted,
'signed_at' => now(),
'rejection_reason' => null,
'reviewed_by' => null,
'reviewed_at' => null,
],
);
$submission->courseRegistrations()->delete();
foreach ($courseClassIds as $courseClassId) {
CourseRegistration::create([
'student_id' => $student->id,
'academic_term_id' => $term->id,
'course_class_id' => $courseClassId,
'submission_id' => $submission->id,
]);
}
$submission->addMedia($signatureFile)->toMediaCollection('signature');
$submission->logs()->create([
'status' => RegistrationStatus::Submitted,
'actor_id' => $student->user_id,
]);
return $submission;
});
}
}

View File

@ -11,7 +11,7 @@ class AcademicTermService
{
public function getAllForSelect(): Collection
{
return AcademicTerm::select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])->latest()->get();
return AcademicTerm::select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])->latest()->get();
}
/**
@ -27,30 +27,26 @@ public function getActive(): ?AcademicTerm
public function paginated(int $perPage = 25, string $search = '', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator
{
return AcademicTerm::query()
->select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])
->when($search, fn($q) => $q->where('academic_year', 'like', "%{$search}%"))
->when($semester, fn($q) => $q->where('semester', $semester))
->when($isActive !== null, fn($q) => $q->where('is_active', $isActive))
->select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when($semester, fn ($q) => $q->where('semester', $semester))
->when($isActive !== null, fn ($q) => $q->where('is_active', $isActive))
->latest()
->paginate($perPage);
}
public function create(array $data): AcademicTerm
{
return AcademicTerm::create([
...$data,
'open_semesters' => $this->normalizeOpenSemesters($data['open_semesters'] ?? []),
]);
return AcademicTerm::create($data);
}
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
{
$academicTerm->academic_year = $data['academic_year'];
$academicTerm->name = $data['name'];
$academicTerm->semester = $data['semester'];
$academicTerm->start_date = $data['start_date'];
$academicTerm->end_date = $data['end_date'];
$academicTerm->is_active = $data['is_active'];
$academicTerm->open_semesters = $this->normalizeOpenSemesters($data['open_semesters'] ?? []);
$academicTerm->update();
return $academicTerm;
@ -71,13 +67,4 @@ public function updateActiveStatus(AcademicTerm $academicTerm, bool $isActive):
$academicTerm->update(['is_active' => $isActive]);
});
}
/**
* @param array<int, mixed> $openSemesters
* @return array<int, int>
*/
private function normalizeOpenSemesters(array $openSemesters): array
{
return array_values(array_unique(array_map('intval', $openSemesters)));
}
}

View File

@ -10,14 +10,7 @@ class CourseService
{
public function getAllForSelect(): Collection
{
return Course::query()
->select(['courses.id', 'courses.code', 'courses.name', 'courses.department_id', 'courses.semester_number'])
->join('departments', 'departments.id', '=', 'courses.department_id')
->with('department:id,name')
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->get();
return Course::select(['id', 'code', 'name', 'department_id'])->get();
}
public function getSemesterNumbers(): array
@ -36,17 +29,14 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
$user = auth()->user();
return Course::query()
->select(['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number'])
->join('departments', 'departments.id', '=', 'courses.department_id')
->select(['id', 'code', 'name', 'credits', 'department_id', 'semester_number'])
->with('department:id,name')
->when($search, fn ($q) => $q->where('courses.name', 'like', "%{$search}%")->orWhere('courses.code', 'like', "%{$search}%"))
->when($departmentId, fn ($q) => $q->where('courses.department_id', $departmentId))
->when($semesterNumber, fn ($q) => $q->where('courses.semester_number', $semesterNumber))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
->when($user->hasRole('dosen'), fn ($q) => $q->whereIn('courses.department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
->when($semesterNumber, fn ($q) => $q->where('semester_number', $semesterNumber))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('department_id', $user->student?->department_id))
->when($user->hasRole('dosen'), fn ($q) => $q->whereIn('department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
->latest()
->paginate($perPage);
}

View File

@ -3,8 +3,6 @@
namespace App\Services\Admin\Master;
use App\Models\Department;
use App\Models\DepartmentLeadership;
use App\Models\Lecturer;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
@ -19,7 +17,6 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
{
return Department::query()
->select(['id', 'code', 'name', 'degree_level'])
->with(['currentLeader.lecturer.user.profile'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->latest()
->paginate($perPage);
@ -27,15 +24,7 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
public function create(array $data): Department
{
$department = Department::create([
'code' => $data['code'],
'name' => $data['name'],
'degree_level' => $data['degree_level'] ?? null,
]);
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
return $department;
return Department::create($data);
}
public function update(Department $department, array $data): Department
@ -45,68 +34,9 @@ public function update(Department $department, array $data): Department
$department->degree_level = $data['degree_level'] ?? null;
$department->update();
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
return $department;
}
private function syncLeadership(Department $department, ?int $lecturerId, ?string $startedAt): void
{
$currentLeader = DepartmentLeadership::query()
->where('department_id', $department->id)
->whereNull('ended_at')
->first();
if (! $lecturerId) {
if ($currentLeader) {
$currentLeader->update(['ended_at' => now()]);
$this->revokeKaprodiRoleIfNoLongerLeading($currentLeader->lecturer_id);
}
return;
}
if ($currentLeader && $currentLeader->lecturer_id === $lecturerId) {
$currentLeader->update(['started_at' => $startedAt ?? $currentLeader->started_at]);
$this->grantKaprodiRole($lecturerId);
return;
}
if ($currentLeader) {
$currentLeader->update(['ended_at' => now()]);
$this->revokeKaprodiRoleIfNoLongerLeading($currentLeader->lecturer_id);
}
$department->leaderships()->create([
'lecturer_id' => $lecturerId,
'started_at' => $startedAt ?? now(),
]);
$this->grantKaprodiRole($lecturerId);
}
private function grantKaprodiRole(int $lecturerId): void
{
$user = Lecturer::find($lecturerId)?->user;
if ($user && ! $user->hasRole('kaprodi')) {
$user->assignRole('kaprodi');
}
}
private function revokeKaprodiRoleIfNoLongerLeading(int $lecturerId): void
{
$stillLeadsAnyDepartment = DepartmentLeadership::query()
->where('lecturer_id', $lecturerId)
->whereNull('ended_at')
->exists();
if (! $stillLeadsAnyDepartment) {
Lecturer::find($lecturerId)?->user?->removeRole('kaprodi');
}
}
public function delete(Department $department): bool
{
return $department->delete();

View File

@ -23,7 +23,7 @@ class PermissionCatalog
public const MANAGE = [
'view-course-classes', 'create-course-classes', 'update-course-classes', 'delete-course-classes',
'view-course-class-enrollments', 'create-course-class-enrollments', 'delete-course-class-enrollments',
'view-course-registrations', 'approve-course-registrations', 'reject-course-registrations',
'view-course-registrations', 'create-course-registrations', 'approve-course-registrations', 'reject-course-registrations',
'view-announcements', 'create-announcements', 'update-announcements', 'delete-announcements',
];

View File

@ -11,12 +11,11 @@ public function up(): void
{
Schema::create('academic_terms', function (Blueprint $table) {
$table->id();
$table->string('academic_year', 20);
$table->string('name', 20);
$table->enum('semester', Semester::values());
$table->date('start_date');
$table->date('end_date');
$table->boolean('is_active')->default(false);
$table->json('open_semesters')->nullable();
$table->timestamps();
});
}

View File

@ -13,7 +13,6 @@ 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();
@ -21,7 +20,7 @@ public function up(): void
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->unique(['student_id', 'academic_term_id', 'semester_number'], 'course_reg_submissions_student_term_semester_unique');
$table->unique(['student_id', 'academic_term_id'], 'course_reg_submissions_student_term_unique');
});
}

View File

@ -12,7 +12,7 @@ public function run(): void
{
AcademicTerm::insert([
[
'academic_year' => '2026/2027',
'name' => '2026/2027',
'semester' => Semester::odd->value,
'start_date' => '2026-08-01',
'end_date' => '2027-01-15',
@ -21,7 +21,7 @@ public function run(): void
'updated_at' => now(),
],
[
'academic_year' => '2026/2027',
'name' => '2026/2027',
'semester' => Semester::even->value,
'start_date' => '2027-02-01',
'end_date' => '2027-07-15',

View File

@ -143,8 +143,6 @@ public function run(): void
['code' => 'MBT707', 'name' => 'KKN Tematik 1', 'credits' => 4, 'department_id' => 2, 'semester_number' => 7],
['code' => 'TIU709', 'name' => 'Skripsi I', 'credits' => 2, 'department_id' => 2, 'semester_number' => 7],
['code' => 'TIU810', 'name' => 'Skripsi II', 'credits' => 4, 'department_id' => 2, 'semester_number' => 8],
// Bisnis Digital (docs/reference/bd.png, bd-2.png) — semester 1 s.d. 8
['code' => 'BDU101', 'name' => 'Ilmu Alamiah, Sosial, dan Budaya Dasar', 'credits' => 2, 'department_id' => 3, 'semester_number' => 1],
['code' => 'BDU102', 'name' => 'Pendidikan Anti Korupsi', 'credits' => 2, 'department_id' => 3, 'semester_number' => 1],

View File

@ -11,35 +11,27 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$seeders = [
$this->call([
RolePermissionSeeder::class,
DepartmentSeeder::class,
AcademicTermSeeder::class,
UserSeeder::class,
];
if (app()->environment('local')) {
$seeders = [
...$seeders,
DepartmentLeadershipSeeder::class,
CourseSeeder::class,
CourseClassSeeder::class,
ClassEnrollmentSeeder::class,
CourseRegistrationSeeder::class,
MaterialSeeder::class,
AssignmentSeeder::class,
SubmissionSeeder::class,
ScheduleSeeder::class,
AttendanceSeeder::class,
TuitionInvoiceSeeder::class,
TuitionPaymentSeeder::class,
AnnouncementSeeder::class,
LetterRequestSeeder::class,
AcademicAdvisingLogSeeder::class,
NotificationSeeder::class,
];
}
$this->call($seeders);
DepartmentLeadershipSeeder::class,
CourseSeeder::class,
CourseClassSeeder::class,
ClassEnrollmentSeeder::class,
CourseRegistrationSeeder::class,
MaterialSeeder::class,
AssignmentSeeder::class,
SubmissionSeeder::class,
ScheduleSeeder::class,
AttendanceSeeder::class,
TuitionInvoiceSeeder::class,
TuitionPaymentSeeder::class,
AnnouncementSeeder::class,
LetterRequestSeeder::class,
AcademicAdvisingLogSeeder::class,
NotificationSeeder::class,
]);
}
}

View File

@ -41,7 +41,6 @@ public function run(): void
'view-dashboard',
'view-academic-terms',
'view-courses',
'view-course-registrations',
'view-letter-requests',
'create-letter-requests',
'update-letter-requests',
@ -53,9 +52,6 @@ public function run(): void
'view-courses',
'view-academic-advising-logs',
'view-students',
'view-course-registrations',
'approve-course-registrations',
'reject-course-registrations',
...$feedbackSelfService,
],
'staff-admin' => [

View File

@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Enums\Gender;
use App\Enums\Semester;
use App\Enums\StudentStatus;
use App\Models\AcademicTerm;
@ -21,28 +20,86 @@ public function run(): void
$users = [
[
'username' => 'pangestu',
'email' => 'project.pangestuyoga@gmail.com',
'roles' => ['developer'],
'email' => 'pangestu@student.itmpwk.ac.id',
'roles' => ['mahasiswa', 'developer'],
'profile' => [
'full_name' => 'Yoga Pangestu',
'phone_number' => '082121495806',
'gender' => Gender::Male,
'address' => 'Jl. Merdeka No. 1, Purwakarta',
'gender' => 'male',
'birth_place' => 'Subang',
'birth_date' => '2005-03-13',
'address' => 'Jl. Raya Cijambe No. 123, Subang, Jawa Barat',
],
'student' => [
'student_number' => '23010001',
'department_id' => 1,
'enrollment_year' => 2023,
'current_semester' => 5,
'academic_advisor_id' => null,
'status' => StudentStatus::Active,
],
],
[
'username' => 'administrator',
'email' => 'administrator@gmail.com',
'roles' => ['staff-admin'],
'username' => 'doni',
'email' => 'doni@student.itmpwk.ac.id',
'roles' => ['mahasiswa'],
'profile' => [
'full_name' => 'Administrator',
'phone_number' => '082121212121',
'gender' => Gender::Male,
'birth_place' => 'Purwakarta',
'birth_date' => '2000-01-01',
'full_name' => 'Doni Setiawan Ramadhan',
'phone_number' => '085793462823',
'address' => 'Jl. Merdeka No. 1, Purwakarta',
'gender' => 'male',
'birth_place' => 'Purwakarta',
'birth_date' => '2004-10-30',
],
'student' => [
'student_number' => '23020002',
'department_id' => 1,
'enrollment_year' => 2023,
'current_semester' => 5,
'academic_advisor_id' => null,
'status' => StudentStatus::Active,
],
],
[
'username' => 'asep',
'email' => 'asep@student.itmpwk.ac.id',
'roles' => ['mahasiswa'],
'profile' => [
'full_name' => 'Asep Saepudin',
'phone_number' => '081546505033',
'address' => 'Jl. Merdeka No. 1, Purwakarta',
'gender' => 'male',
'birth_place' => 'Purwakarta',
'birth_date' => '2005-09-24',
],
'student' => [
'student_number' => '24010003',
'department_id' => 1,
'enrollment_year' => 2024,
'current_semester' => 3,
'academic_advisor_id' => null,
'status' => StudentStatus::Active,
],
],
[
'username' => 'komala',
'email' => 'komala@student.itmpwk.ac.id',
'roles' => ['mahasiswa'],
'profile' => [
'full_name' => 'Komala Dewi',
'phone_number' => '085819894938',
'address' => 'Jl. Merdeka No. 1, Purwakarta',
'gender' => 'female',
'birth_place' => 'Purwakarta',
'birth_date' => '2005-05-04',
],
'student' => [
'student_number' => '24020004',
'department_id' => 1,
'enrollment_year' => 2024,
'current_semester' => 3,
'academic_advisor_id' => null,
'status' => StudentStatus::Active,
],
],
];
@ -78,11 +135,9 @@ public function run(): void
}
}
if (app()->environment('local')) {
$this->seedRandomLecturers();
$this->seedRandomStudents();
$this->seedOtherStaff();
}
$this->seedRandomLecturers();
$this->seedRandomStudents();
$this->seedOtherStaff();
}
/**
@ -138,7 +193,7 @@ private function seedRandomStudents(): void
$departments = Department::all();
foreach (AcademicTerm::all() as $term) {
[$startYear, $endYear] = explode('/', $term->academic_year);
[$startYear, $endYear] = explode('/', $term->name);
$enrollmentYear = $term->semester === Semester::even ? (int) $endYear : (int) $startYear;
foreach ($departments as $department) {

View File

@ -54,11 +54,16 @@ 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);
@ -112,6 +117,17 @@ function buildNavMain({
];
const kelolaItems: NavItem[] = [
...(isMahasiswa || can('view-course-registrations')
? [
{
name: 'Registrasi KRS',
url: isMahasiswa
? studentCourseRegistrationsRoute.url()
: courseRegistrationsRoute.url(),
icon: FileCheck2,
},
]
: []),
...(can('view-course-classes')
? [
{
@ -121,15 +137,6 @@ function buildNavMain({
},
]
: []),
...(can('view-course-registrations')
? [
{
name: 'Registrasi KRS',
url: courseRegistrationsRoute.url(),
icon: FileCheck2,
},
]
: []),
...(can('view-announcements')
? [
{
@ -282,8 +289,14 @@ 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({ permissions });
const navMain = buildNavMain({
isMahasiswa,
permissions,
});
return (
<Sidebar collapsible="icon" {...props}>

View File

@ -65,7 +65,6 @@ interface DataTableProps<TData, TValue> {
toolbar?: React.ReactNode;
renderSubRow?: (row: Row<TData>, searchValue?: string) => React.ReactNode;
defaultExpanded?: boolean;
groupBy?: (item: TData) => string;
pagination?: PaginationState;
onPageChange?: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
@ -157,7 +156,6 @@ export function DataTable<TData, TValue>({
toolbar,
renderSubRow,
defaultExpanded = false,
groupBy,
pagination,
onPageChange,
onPerPageChange,
@ -406,96 +404,58 @@ export function DataTable<TData, TValue>({
</SortableContext>
</DndContext>
) : (
(() => {
let previousGroup: string | null =
null;
return table
.getRowModel()
.rows.map((row) => {
const group = groupBy
? groupBy(row.original)
: null;
const showGroupHeader =
groupBy &&
group !== previousGroup;
previousGroup = group;
return (
<React.Fragment
key={row.id}
>
{showGroupHeader && (
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableCell
colSpan={
visibleColumns.length
table.getRowModel().rows.map((row) => (
<React.Fragment key={row.id}>
<TableRow
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={
(
cell.column
.columnDef
.meta as {
className?: string;
}
className="py-2 text-sm font-semibold"
>
{group}
</TableCell>
</TableRow>
)}
<TableRow
data-state={
row.getIsSelected() &&
'selected'
)?.className
}
>
{row
.getVisibleCells()
.map(
(cell) => (
<TableCell
key={
cell.id
}
className={
(
cell
.column
.columnDef
.meta as {
className?: string;
}
)
?.className
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
),
)}
</TableRow>
{renderSubRow &&
row.getIsExpanded() && (
<TableRow>
<TableCell
colSpan={
visibleColumns.length
}
className="bg-muted/50 p-0"
>
<div className="p-4">
{renderSubRow(
row,
localSearch,
)}
</div>
</TableCell>
</TableRow>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
</React.Fragment>
);
});
})()
</TableCell>
))}
</TableRow>
{renderSubRow &&
row.getIsExpanded() && (
<TableRow>
<TableCell
colSpan={
visibleColumns.length
}
className="bg-muted/50 p-0"
>
<div className="p-4">
{renderSubRow(
row,
localSearch,
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
))
)
) : (
<TableRow>

View File

@ -19,17 +19,13 @@ type DatePickerProps = {
maxDate?: Date;
};
const defaultMaxDate = new Date(
new Date().setFullYear(new Date().getFullYear() + 5),
);
export function DatePicker({
value,
onChange,
placeholder = 'Pilih tanggal',
className,
disabled,
maxDate = defaultMaxDate,
maxDate,
}: DatePickerProps) {
return (
<Popover>
@ -54,7 +50,6 @@ export function DatePicker({
defaultMonth={value ?? undefined}
captionLayout="dropdown"
onSelect={onChange}
endMonth={maxDate}
disabled={maxDate ? { after: maxDate } : undefined}
/>
</PopoverContent>

View File

@ -62,7 +62,7 @@ export function FormDialog({
<DialogHeader className="shrink-0">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="grid flex-1 gap-4 overflow-x-hidden overflow-y-auto px-1 py-4">
<div className="grid flex-1 gap-4 overflow-x-hidden overflow-y-auto py-4">
{typeof children === 'function'
? children({ errors, processing })
: children}

View File

@ -1,5 +1,5 @@
import { Eraser } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import SignatureCanvasImport from 'react-signature-canvas';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
@ -18,49 +18,12 @@ 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,
compact,
onCapture,
}: SignaturePadProps) {
export function SignaturePad({ name, error }: 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;
@ -80,7 +43,6 @@ export function SignaturePad({
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
onCapture?.(file);
}, 'image/png');
}
@ -90,41 +52,6 @@ export function SignaturePad({
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onCapture?.(null);
}
if (compact) {
return (
<div className="grid gap-1">
<div className="relative h-28 w-48 overflow-hidden rounded border bg-white">
<SignatureCanvas
ref={padRef}
penColor="#0f172a"
canvasProps={{
className:
'h-28 w-48 cursor-crosshair touch-none',
}}
onEnd={syncFileInput}
/>
<button
type="button"
onClick={handleClear}
aria-label="Hapus tanda tangan"
className="absolute top-0.5 right-0.5 rounded bg-white/80 p-0.5 text-slate-500 hover:text-slate-900"
>
<Eraser className="h-3 w-3" />
</button>
</div>
<input
ref={fileInputRef}
type="file"
name={name}
className="hidden"
/>
<InputError message={error} />
</div>
);
}
return (

View File

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

View File

@ -51,7 +51,7 @@ export function createTuitionInvoiceColumns(
},
},
{
accessorKey: 'academic_term.academic_year',
accessorKey: 'academic_term.name',
header: () => <span>Periode</span>,
cell: ({ row }) => {
const term = row.original.academic_term;

View File

@ -69,11 +69,7 @@ import {
} from '@/types/tuition-payment';
import { createTuitionInvoiceColumns } from './columns';
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type AcademicTermOption = { id: number; name: string; semester: string };
type InvoiceSummary = {
unpaid_students: number;

View File

@ -3,6 +3,7 @@ import { Pencil, Trash2, Users } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { index as enrollmentsIndex } from '@/routes/admin/manage/course-classes/enrollments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import { ClassMethodLabels } from '@/types/course-class';
import type { CourseClass } from '@/types/course-class';
@ -43,6 +44,15 @@ export function createCourseClassColumns(
cell: ({ row }) =>
row.original.lecturer?.user?.profile?.full_name ?? '-',
},
{
accessorKey: 'academic_term.name',
header: () => <span>Periode</span>,
cell: ({ row }) => {
const term = row.original.academic_term;
return term ? formatAcademicTermLabel(term) : '-';
},
},
{
accessorKey: 'method',
header: () => <span className="block text-center">Metode</span>,

View File

@ -1,33 +1,47 @@
import { Head, Link, router } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { ArrowLeft, Trash2 } from 'lucide-react';
import { ArrowLeft, Trash2, UserPlus } from 'lucide-react';
import { useState } from 'react';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { RowActions } from '@/components/row-actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { usePermissions } from '@/hooks/use-permissions';
import { index as courseClassIndex } from '@/routes/admin/manage/course-classes';
import { destroy } from '@/routes/admin/manage/course-classes/enrollments';
import {
destroy,
store,
} from '@/routes/admin/manage/course-classes/enrollments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { ClassEnrollment } from '@/types/class-enrollment';
import type {
ClassEnrollment,
EnrollmentStudent,
} from '@/types/class-enrollment';
import type { CourseClass } from '@/types/course-class';
import { ClassMethodLabels } from '@/types/course-class';
type Props = {
courseClass: CourseClass;
enrollments: ClassEnrollment[];
availableStudents: EnrollmentStudent[];
};
export default function ClassEnrollmentIndex({
courseClass,
enrollments,
availableStudents,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [deleting, setDeleting] = useState<ClassEnrollment | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-course-class-enrollments');
const canDelete = hasPermission('delete-course-class-enrollments');
function handleDelete() {
@ -144,10 +158,25 @@ export default function ClassEnrollmentIndex({
</CardContent>
</Card>
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
{canCreate && (
<Button onClick={() => setCreateOpen(true)}>
<UserPlus className="h-4 w-4" />
Tambah Mahasiswa
</Button>
)}
</div>
<DataTable columns={columns} data={enrollments} />
<EnrollForm
open={createOpen}
onOpenChange={setCreateOpen}
courseClassId={courseClass.id}
availableStudents={availableStudents}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
@ -165,3 +194,123 @@ export default function ClassEnrollmentIndex({
</>
);
}
function EnrollForm({
open,
onOpenChange,
courseClassId,
availableStudents,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courseClassId: number;
availableStudents: EnrollmentStudent[];
}) {
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const allSelected =
availableStudents.length > 0 &&
selectedIds.length === availableStudents.length;
function toggleAll() {
setSelectedIds(
allSelected ? [] : availableStudents.map((student) => student.id),
);
}
function toggleOne(id: number) {
setSelectedIds((prev) =>
prev.includes(id)
? prev.filter((selectedId) => selectedId !== id)
: [...prev, id],
);
}
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Mahasiswa"
action={store(courseClassId)}
resetOnSuccess
submitDisabled={selectedIds.length === 0}
submitLabel={
selectedIds.length > 0
? `Tambahkan (${selectedIds.length})`
: 'Tambahkan'
}
onSuccess={() => {
onOpenChange(false);
setSelectedIds([]);
}}
>
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>
Mahasiswa{' '}
<span className="text-destructive">*</span>
</Label>
{availableStudents.length > 0 && (
<div className="flex items-center gap-2">
<Checkbox
id="select-all-students"
checked={allSelected}
onCheckedChange={toggleAll}
/>
<Label
htmlFor="select-all-students"
className="text-sm font-normal"
>
Pilih Semua
</Label>
</div>
)}
</div>
{selectedIds.map((id) => (
<input
key={id}
type="hidden"
name="student_ids[]"
value={id}
/>
))}
<div className="max-h-64 overflow-y-auto rounded-md border">
{availableStudents.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
Tidak ada mahasiswa dari jurusan yang sama
untuk didaftarkan.
</p>
) : (
availableStudents.map((student) => (
<label
key={student.id}
htmlFor={`student-${student.id}`}
className="flex items-center gap-2 border-b px-3 py-2 last:border-b-0 hover:bg-muted/50"
>
<Checkbox
id={`student-${student.id}`}
checked={selectedIds.includes(
student.id,
)}
onCheckedChange={() =>
toggleOne(student.id)
}
/>
<span className="text-sm">
{student.user?.profile?.full_name ??
'N/A'}{' '}
- {student.student_number}
</span>
</label>
))
)}
</div>
<InputError message={errors.student_ids} />
</div>
</div>
)}
</FormDialog>
);
}

View File

@ -1,5 +1,5 @@
import { Head, router } from '@inertiajs/react';
import { Copy, Plus } from 'lucide-react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
@ -12,18 +12,11 @@ import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
useComboboxAnchor,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import {
@ -38,7 +31,6 @@ import { useServerTable } from '@/hooks/use-server-table';
import {
index as courseClassIndex,
destroy,
duplicate,
store,
update,
} from '@/routes/admin/manage/course-classes';
@ -47,62 +39,14 @@ import { ClassMethodLabels, ClassMethods } from '@/types/course-class';
import type { CourseClass } from '@/types/course-class';
import { createCourseClassColumns } from './columns';
type Course = {
id: number;
code: string;
name: string;
department_id: number;
department: { id: number; name: string } | null;
semester_number: number | null;
};
type CourseGroup = { value: string; items: Course[] };
type Course = { id: number; code: string; name: string; department_id: number };
type Lecturer = {
id: number;
lecturer_number: string;
departments: { id: number; name: string }[];
user: { profile: { full_name: string } | null } | null;
};
type AcademicTerm = {
id: number;
academic_year: string;
semester: string;
};
type CourseClassAssignment = {
id: number;
course_id: number;
academic_term_id: number;
};
function courseMatchesTermParity(
course: Course,
term: AcademicTerm | undefined,
): boolean {
if (!term || course.semester_number === null) {
return true;
}
const isOddSemester = course.semester_number % 2 === 1;
return term.semester === 'odd' ? isOddSemester : !isOddSemester;
}
function groupCoursesByDepartmentAndSemester(courses: Course[]): CourseGroup[] {
const groups: CourseGroup[] = [];
let currentKey: string | null = null;
for (const course of courses) {
const key = `${course.department?.name ?? 'Tanpa Jurusan'} — Semester ${course.semester_number ?? 'Tidak ditentukan'}`;
if (key !== currentKey) {
currentKey = key;
groups.push({ value: key, items: [] });
}
groups[groups.length - 1].items.push(course);
}
return groups;
}
type AcademicTerm = { id: number; name: string; semester: string };
type Props = {
courseClasses: {
@ -115,7 +59,6 @@ type Props = {
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
highlight?: number;
filters: {
academic_term_id?: string;
@ -128,12 +71,10 @@ export default function CourseClassIndex({
courses,
lecturers,
academicTerms,
courseClassAssignments,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [editing, setEditing] = useState<CourseClass | null>(null);
const [deleting, setDeleting] = useState<CourseClass | null>(null);
const { hasPermission } = usePermissions();
@ -229,24 +170,15 @@ export default function CourseClassIndex({
}
actions={
canCreate && (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDuplicateOpen(true)}
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Copy className="h-4 w-4" />
Duplikat dari Periode
</Button>
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
</div>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>
@ -257,13 +189,6 @@ export default function CourseClassIndex({
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<DuplicateForm
open={duplicateOpen}
onOpenChange={setDuplicateOpen}
academicTerms={academicTerms}
/>
<EditForm
@ -278,7 +203,6 @@ export default function CourseClassIndex({
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<DataTable
@ -290,9 +214,6 @@ export default function CourseClassIndex({
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
groupBy={(courseClass) =>
`${courseClass.academic_term ? formatAcademicTermLabel(courseClass.academic_term) : 'Tanpa Periode'} — Semester ${courseClass.course?.semester_number ?? 'Tidak ditentukan'}`
}
toolbar={
<FilterDialog
fields={filterFields}
@ -326,46 +247,28 @@ function CreateForm({
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [academicTermId, setAcademicTermId] = useState('');
const [selectedCourses, setSelectedCourses] = useState<Course[]>([]);
const [courseId, setCourseId] = useState('');
const [lecturer, setLecturer] = useState<Lecturer | null>(null);
const courseAnchor = useComboboxAnchor();
const selectedTerm = academicTerms.find(
(term) => String(term.id) === academicTermId,
);
const takenCourseIds = new Set(
courseClassAssignments
.filter((a) => String(a.academic_term_id) === academicTermId)
.map((a) => a.course_id),
);
const selectedCourseIds = new Set(selectedCourses.map((c) => c.id));
const lecturerDepartmentIds = new Set(
lecturer ? lecturer.departments.map((d) => d.id) : [],
);
const availableCourses = lecturer
? courses.filter(
(c) =>
lecturerDepartmentIds.has(c.department_id) &&
!takenCourseIds.has(c.id) &&
!selectedCourseIds.has(c.id) &&
courseMatchesTermParity(c, selectedTerm),
const selectedCourse = courses.find((c) => String(c.id) === courseId);
const availableLecturers = selectedCourse
? lecturers.filter((l) =>
l.departments.some(
(department) =>
department.id === selectedCourse.department_id,
),
)
: [];
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
: lecturers;
function reset() {
setAcademicTermId('');
setSelectedCourses([]);
setCourseId('');
setLecturer(null);
}
@ -388,18 +291,8 @@ function CreateForm({
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={(value) => {
setAcademicTermId(value);
setSelectedCourses([]);
}}
>
<input type="hidden" name="academic_term_id" />
<Select name="academic_term_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
@ -416,6 +309,39 @@ function CreateForm({
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2">
<Label>
Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="course_id"
value={courseId}
/>
<Select
value={courseId}
onValueChange={(value) => {
setCourseId(value);
setLecturer(null);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih mata kuliah" />
</SelectTrigger>
<SelectContent>
{courses.map((course) => (
<SelectItem
key={course.id}
value={String(course.id)}
>
{course.code} - {course.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.course_id} />
</div>
<div className="grid gap-2">
<Label>
Dosen Pengampu{' '}
@ -427,19 +353,22 @@ function CreateForm({
value={lecturer?.id ?? ''}
/>
<Combobox
items={lecturers}
items={availableLecturers}
value={lecturer}
onValueChange={(value) => {
setLecturer(value);
setSelectedCourses([]);
}}
onValueChange={setLecturer}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!selectedCourse}
>
<ComboboxInput
placeholder="Pilih dosen pengampu"
disabled={!selectedCourse}
placeholder={
selectedCourse
? 'Pilih dosen pengampu'
: 'Pilih mata kuliah terlebih dahulu'
}
className="w-full"
/>
<ComboboxContent>
@ -462,78 +391,6 @@ function CreateForm({
</Combobox>
<InputError message={errors.lecturer_id} />
</div>
<div className="grid gap-2">
<Label>
Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
{selectedCourses.map((c) => (
<input
key={c.id}
type="hidden"
name="course_ids[]"
value={c.id}
/>
))}
<Combobox
items={courseGroups}
multiple
value={selectedCourses}
onValueChange={setSelectedCourses}
itemToStringLabel={(c) => `${c.code} - ${c.name}`}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!lecturer}
>
<ComboboxChips ref={courseAnchor}>
{selectedCourses.map((c) => (
<ComboboxChip
key={c.id}
aria-label={`${c.code} - ${c.name}`}
>
{c.code}
</ComboboxChip>
))}
<ComboboxChipsInput
disabled={!lecturer}
placeholder={
!lecturer
? 'Pilih dosen pengampu terlebih dahulu'
: selectedCourses.length === 0
? 'Pilih mata kuliah (bisa lebih dari satu)'
: ''
}
/>
</ComboboxChips>
<ComboboxContent anchor={courseAnchor}>
<ComboboxEmpty>
Mata kuliah tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(group: CourseGroup) => (
<ComboboxGroup
key={group.value}
items={group.items}
>
<ComboboxLabel>
{group.value}
</ComboboxLabel>
<ComboboxCollection>
{(c: Course) => (
<ComboboxItem
key={c.id}
value={c}
>
{c.code} - {c.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.course_ids} />
</div>
<div className="grid gap-2">
<Label>Metode</Label>
<input type="hidden" name="method" />
@ -564,7 +421,6 @@ function EditForm({
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -572,15 +428,9 @@ function EditForm({
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [academicTermId, setAcademicTermId] = useState(
editing ? String(editing.academic_term_id) : '',
);
const [course, setCourse] = useState<Course | null>(
editing
? (courses.find((c) => c.id === editing.course_id) ?? null)
: null,
const [courseId, setCourseId] = useState(
editing ? String(editing.course_id) : '',
);
const [lecturer, setLecturer] = useState<Lecturer | null>(
editing
@ -588,30 +438,15 @@ function EditForm({
: null,
);
const selectedTerm = academicTerms.find(
(term) => String(term.id) === academicTermId,
);
const takenCourseIds = new Set(
courseClassAssignments
.filter(
(a) =>
String(a.academic_term_id) === academicTermId &&
a.id !== editing?.id,
)
.map((a) => a.course_id),
);
const lecturerDepartmentIds = new Set(
lecturer ? lecturer.departments.map((d) => d.id) : [],
);
const availableCourses = lecturer
? courses.filter(
(c) =>
lecturerDepartmentIds.has(c.department_id) &&
!takenCourseIds.has(c.id) &&
courseMatchesTermParity(c, selectedTerm),
const selectedCourse = courses.find((c) => String(c.id) === courseId);
const availableLecturers = selectedCourse
? lecturers.filter((l) =>
l.departments.some(
(department) =>
department.id === selectedCourse.department_id,
),
)
: [];
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
: lecturers;
return (
<FormDialog
@ -627,36 +462,36 @@ function EditForm({
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
name="course_id"
value={courseId}
/>
<Select
value={academicTermId}
value={courseId}
onValueChange={(value) => {
setAcademicTermId(value);
setCourse(null);
setCourseId(value);
setLecturer(null);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
<SelectValue placeholder="Pilih mata kuliah" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
{courses.map((course) => (
<SelectItem
key={term.id}
value={String(term.id)}
key={course.id}
value={String(course.id)}
>
{formatAcademicTermLabel(term)}
{course.code} - {course.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
<InputError message={errors.course_id} />
</div>
<div className="grid gap-2">
<Label>
@ -669,19 +504,22 @@ function EditForm({
value={lecturer?.id ?? ''}
/>
<Combobox
items={lecturers}
items={availableLecturers}
value={lecturer}
onValueChange={(value) => {
setLecturer(value);
setCourse(null);
}}
onValueChange={setLecturer}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!selectedCourse}
>
<ComboboxInput
placeholder="Pilih dosen pengampu"
disabled={!selectedCourse}
placeholder={
selectedCourse
? 'Pilih dosen pengampu'
: 'Pilih mata kuliah terlebih dahulu'
}
className="w-full"
/>
<ComboboxContent>
@ -706,63 +544,28 @@ function EditForm({
</div>
<div className="grid gap-2">
<Label>
Mata Kuliah{' '}
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="course_id"
value={course?.id ?? ''}
/>
<Combobox
items={courseGroups}
value={course}
onValueChange={setCourse}
itemToStringLabel={(c) =>
`${c.code} - ${c.name}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!lecturer}
<Select
name="academic_term_id"
defaultValue={String(editing.academic_term_id)}
>
<ComboboxInput
disabled={!lecturer}
placeholder={
lecturer
? 'Pilih mata kuliah'
: 'Pilih dosen pengampu terlebih dahulu'
}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Mata kuliah tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(group: CourseGroup) => (
<ComboboxGroup
key={group.value}
items={group.items}
>
<ComboboxLabel>
{group.value}
</ComboboxLabel>
<ComboboxCollection>
{(c: Course) => (
<ComboboxItem
key={c.id}
value={c}
>
{c.code} -{' '}
{c.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.course_id} />
<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>Metode</Label>
@ -789,133 +592,3 @@ function EditForm({
</FormDialog>
);
}
function DuplicateForm({
open,
onOpenChange,
academicTerms,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
academicTerms: AcademicTerm[];
}) {
const [sourceTermId, setSourceTermId] = useState('');
const [targetTermId, setTargetTermId] = useState('');
const sourceTerm = academicTerms.find(
(term) => String(term.id) === sourceTermId,
);
const targetOptions = academicTerms.filter(
(term) =>
String(term.id) !== sourceTermId &&
(!sourceTerm || term.semester === sourceTerm.semester),
);
function reset() {
setSourceTermId('');
setTargetTermId('');
}
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Duplikat Kelas Mata Kuliah"
action={duplicate()}
resetOnSuccess
submitLabel="Duplikat"
submittingLabel="Menduplikasi..."
onSuccess={() => {
onOpenChange(false);
reset();
}}
>
{({ errors }) => (
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
Menyalin semua kelas mata kuliah (mata kuliah, dosen
pengampu, metode) dari periode sumber ke periode
tujuan. Periode tujuan hanya bisa dipilih dari
semester yang sama (Ganjil ke Ganjil, Genap ke Genap).
Mata kuliah yang sudah punya kelas di periode tujuan
akan dilewati.
</p>
<div className="grid gap-2">
<Label>
Dari Periode{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="source_academic_term_id"
value={sourceTermId}
/>
<Select
value={sourceTermId}
onValueChange={(value) => {
setSourceTermId(value);
setTargetTermId('');
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode sumber" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError
message={errors.source_academic_term_id}
/>
</div>
<div className="grid gap-2">
<Label>
Ke Periode{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="target_academic_term_id"
value={targetTermId}
/>
<Select
value={targetTermId}
onValueChange={setTargetTermId}
disabled={!sourceTermId}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
sourceTermId
? 'Pilih periode tujuan'
: 'Pilih periode sumber terlebih dahulu'
}
/>
</SelectTrigger>
<SelectContent>
{targetOptions.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError
message={errors.target_academic_term_id}
/>
</div>
</div>
)}
</FormDialog>
);
}

View File

@ -1,12 +1,25 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Eye } from 'lucide-react';
import { Check, Eye, X } 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 { CourseRegistrationRow } from '@/types/course-registration';
import type { CourseRegistrationSubmission } from '@/types/course-registration';
import { RegistrationStatusLabels } from '@/types/course-registration';
export 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 function createCourseRegistrationColumns(): ColumnDef<CourseRegistrationRow>[] {
return [
{
id: 'student',
@ -20,35 +33,98 @@ export function createCourseRegistrationColumns(): ColumnDef<CourseRegistrationR
{student?.user?.profile?.full_name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{student?.student_number}
{student?.student_number} &middot;{' '}
{student?.department?.name ?? '-'}
</p>
</div>
);
},
},
{
id: 'department',
header: () => <span>Jurusan</span>,
cell: ({ row }) => row.original.student?.department?.name ?? '-',
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: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
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),
},
]}
/>
);
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(row.original.student_id),
},
]}
/>
),
},
];
}

View File

@ -1,43 +1,118 @@
import { Head } from '@inertiajs/react';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from '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 } from '@/routes/admin/manage/course-registrations';
import {
index as courseRegistrationIndex,
approve,
reject,
store,
} from '@/routes/admin/manage/course-registrations';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type {
CourseRegistrationRow,
DepartmentSummary,
CourseRegistrationCourseClass,
CourseRegistrationStudent,
CourseRegistrationSubmission,
} from '@/types/course-registration';
import {
RegistrationStatuses,
RegistrationStatusLabels,
} 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: CourseRegistrationRow[];
data: CourseRegistrationSubmission[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[];
highlight?: number;
filters: {
department_id?: string;
status?: string;
academic_term_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: 'department_id',
label: 'Jurusan',
options: departments.map((department) => ({
value: String(department.id),
label: department.name,
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),
})),
},
];
@ -56,19 +131,58 @@ export default function CourseRegistrationIndex({
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => index.url(),
route: () => courseRegistrationIndex.url(),
pagination,
filters,
});
const columns = createCourseRegistrationColumns();
function handleApprove(submission: CourseRegistrationSubmission) {
router.patch(approve(submission.id));
}
const columns = createCourseRegistrationColumns({
handleApprove,
handleRejectClick: (submission) => setRejecting(submission),
canApprove,
canReject,
});
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" />
<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}
/>
<DataTable
columns={columns}
@ -87,7 +201,257 @@ export default function CourseRegistrationIndex({
/>
}
/>
<RejectForm
open={rejecting !== null}
onOpenChange={(open) => {
if (!open) {
setRejecting(null);
}
}}
submission={rejecting}
/>
</div>
</>
);
}
function RejectForm({
open,
onOpenChange,
submission,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
submission: CourseRegistrationSubmission | null;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tolak Registrasi KRS"
action={submission ? reject(submission.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
KRS milik{' '}
<span className="font-medium text-foreground">
{submission?.student?.user?.profile?.full_name ??
'mahasiswa ini'}
</span>{' '}
akan ditolak. Mahasiswa perlu memperbaiki dan mengajukan
ulang.
</p>
<div className="grid gap-2">
<Label htmlFor="reason">
Alasan Penolakan{' '}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reason"
name="reason"
placeholder="Jelaskan alasan penolakan"
/>
<InputError message={errors.reason} />
</div>
</div>
)}
</FormDialog>
);
}
function RegistrationFields({
errors,
students,
academicTerms,
courseClasses,
}: {
errors: Record<string, string>;
students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[];
}) {
const activeTermId = academicTerms.find((term) => term.is_active)?.id;
const [student, setStudent] = useState<CourseRegistrationStudent | null>(
null,
);
const [academicTermId, setAcademicTermId] = useState(() =>
activeTermId ? String(activeTermId) : '',
);
const availableCourseClasses = courseClasses.filter(
(courseClass) =>
student &&
academicTermId &&
courseClass.academic_term_id === Number(academicTermId) &&
courseClass.course?.semester_number === student.current_semester &&
courseClass.course?.department_id === student.department?.id,
);
const selectionKey = `${student?.id ?? ''}-${academicTermId}`;
const [selected, setSelected] = useState<number[]>([]);
const [selectedForKey, setSelectedForKey] = useState(selectionKey);
if (selectionKey !== selectedForKey) {
setSelectedForKey(selectionKey);
setSelected([]);
}
function toggle(courseClassId: number, checked: boolean) {
setSelected((prev) =>
checked
? [...prev, courseClassId]
: prev.filter((id) => id !== courseClassId),
);
}
return (
<>
<div className="grid gap-2">
<Label>
Mahasiswa <span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="student_id"
value={student?.id ?? ''}
/>
<Combobox
items={students}
value={student}
onValueChange={setStudent}
itemToStringLabel={studentLabel}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxInput
placeholder="Pilih mahasiswa"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Mahasiswa tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(option: CourseRegistrationStudent) => (
<ComboboxItem key={option.id} value={option}>
{studentLabel(option)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.student_id} />
</div>
<div className="grid gap-2">
<Label>
Periode Akademik <span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={setAcademicTermId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem key={term.id} value={String(term.id)}>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2">
<Label>
Kelas Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
{!student || !academicTermId ? (
<p className="text-sm text-muted-foreground">
Pilih mahasiswa dan periode terlebih dahulu.
</p>
) : availableCourseClasses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Tidak ada kelas mata kuliah yang tersedia untuk semester
mahasiswa ini.
</p>
) : (
<div className="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2">
{availableCourseClasses.map((courseClass) => (
<label
key={courseClass.id}
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted"
>
<Checkbox
checked={selected.includes(courseClass.id)}
onCheckedChange={(checked) =>
toggle(courseClass.id, checked === true)
}
/>
<span className="text-sm">
{courseClassLabel(courseClass)}
</span>
</label>
))}
</div>
)}
{selected.map((courseClassId) => (
<input
key={courseClassId}
type="hidden"
name="course_class_ids[]"
value={courseClassId}
/>
))}
<InputError message={errors.course_class_ids} />
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
students,
academicTerms,
courseClasses,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
students: CourseRegistrationStudent[];
academicTerms: AcademicTermOption[];
courseClasses: CourseRegistrationCourseClass[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Registrasi KRS"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<RegistrationFields
errors={errors}
students={students}
academicTerms={academicTerms}
courseClasses={courseClasses}
/>
</div>
)}
</FormDialog>
);
}

View File

@ -1,162 +1,150 @@
import { Form, Head, Link, router } from '@inertiajs/react';
import { Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft, Check, Inbox, Send, X } from 'lucide-react';
import { useState } from 'react';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { ArrowLeft } from 'lucide-react';
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 { 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';
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';
const RegistrationStatusVariants: Record<
CourseRegistrationSubmission['status'],
'default' | 'secondary' | 'destructive' | 'outline'
> = {
submitted: 'outline',
approved: 'default',
rejected: 'destructive',
type Props = {
submission: CourseRegistrationSubmission;
};
function isSemesterOpenForStudent(
semester: number | null,
openSemesters: number[],
studentCurrentSemester: number,
): boolean {
export default function CourseRegistrationShow({ submission }: Props) {
const student = submission.student;
return (
semester !== null &&
semester === studentCurrentSemester &&
openSemesters.includes(semester)
);
}
<>
<Head title="Detail Registrasi KRS" />
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 className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Registrasi KRS"
actions={
<Button variant="outline" asChild>
<Link href={courseRegistrationIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>
{student?.user?.profile?.full_name ?? 'N/A'}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{student?.student_number} &middot;{' '}
{student?.department?.name ?? '-'} &middot;
Semester {student?.current_semester ?? '-'}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Periode:{' '}
{submission.academic_term
? formatAcademicTermLabel(
submission.academic_term,
)
: '-'}
</p>
</div>
<Badge
variant={
submission.status === 'approved'
? 'default'
: submission.status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[submission.status]}
</Badge>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Tanda Tangan
</CardTitle>
</CardHeader>
<CardContent>
{submission.signature_url ? (
<div className="w-fit rounded-md border bg-white p-2">
<img
src={submission.signature_url}
alt="Tanda tangan"
className="h-24"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
Tidak ada tanda tangan.
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Ditandatangani pada{' '}
{format(
new Date(submission.signed_at),
'd MMM yyyy, HH:mm',
)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Mata Kuliah (
{submission.course_registrations.length})
</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.course_registrations.map((registration) => (
<div
key={registration.id}
className="rounded-md border p-3"
>
<p className="font-medium">
{registration.course_class?.course?.name ??
'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{registration.course_class?.course?.code ??
'-'}
</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Riwayat</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.logs.map((log, index) => (
<LogBubble
key={log.id}
log={log}
isResubmission={
log.status === 'submitted' &&
submission.logs
.slice(0, index)
.some((l) => l.status === 'submitted')
}
/>
))}
</CardContent>
</Card>
</div>
);
}
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 (
<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>
</>
);
}
@ -164,7 +152,7 @@ function LogBubble({
log,
isResubmission,
}: {
log: CourseRegistrationSubmissionLog;
log: CourseRegistrationSubmission['logs'][number];
isResubmission: boolean;
}) {
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
@ -194,563 +182,3 @@ 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.credits
: sum
: isSemesterOpen && registration.course_class
? sum + registration.course.credits
: 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;
const checked =
submission.has_registrations
? registration.is_registered
: isSemesterOpen;
return (
<TableRow
key={
registration.id
}
>
<TableCell>
{index + 1}
</TableCell>
<TableCell>
{course.name}
</TableCell>
<TableCell>
{course.code}
</TableCell>
<TableCell className="text-center">
{
course.credits
}
</TableCell>
<TableCell className="text-center">
{registration.course_class ? (
<Checkbox
name="course_class_ids[]"
value={
registration
.course_class
.id
}
defaultChecked={
checked
}
disabled={
!isSemesterOpen ||
isViewOnly ||
isLocked
}
/>
) : (
<span
className="text-xs text-muted-foreground"
title="Kelas belum dibuka untuk periode ini"
>
</span>
)}
</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={
isSemesterOpen &&
!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[];
studentCurrentSemester: number;
hasActiveTerm: boolean;
backHref: string | null;
canReview: boolean;
canSignAsKaprodi: boolean;
canSignAsAdvisor: boolean;
};
export default function CourseRegistrationDetail({
submissions,
semesters,
openSemesters,
studentCurrentSemester,
hasActiveTerm,
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'}
{!isSemesterOpenForStudent(
semester,
openSemesters,
studentCurrentSemester,
) && ' (Ditutup)'}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{currentSubmission ? (
<SemesterCard
key={currentKey}
semester={currentSemester}
submission={currentSubmission}
studentId={studentId}
isSemesterOpen={isSemesterOpenForStudent(
currentSemester,
openSemesters,
studentCurrentSemester,
)}
backHref={backHref}
canReview={canReview}
canSignAsKaprodi={canSignAsKaprodi}
canSignAsAdvisor={canSignAsAdvisor}
/>
) : (
semesters.length > 0 &&
!hasActiveTerm && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground" />
<p className="font-medium">
Belum ada periode akademik aktif
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Registrasi KRS hanya dapat dilakukan saat
ada periode akademik yang aktif. Silakan
hubungi admin untuk informasi lebih
lanjut.
</p>
</CardContent>
</Card>
)
)}
{semesters.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground" />
<p className="font-medium">
Belum ada mata kuliah terdaftar
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Belum ada mata kuliah yang terdaftar untuk
jurusan ini. Silakan hubungi admin atau
program studi.
</p>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@ -2,7 +2,6 @@ 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';
@ -32,11 +31,11 @@ export function createAcademicTermColumns(
const columns: ColumnDef<AcademicTerm>[] = [
{
accessorKey: 'academic_year',
header: () => <span>Tahun Ajaran</span>,
accessorKey: 'name',
header: () => <span>Nama</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('academic_year') as string}
{row.getValue('name') as string}
</span>
),
},
@ -67,33 +66,6 @@ export function createAcademicTermColumns(
</span>
),
},
{
id: 'open_semesters',
header: () => <span>Semester Dibuka</span>,
cell: ({ row }) => {
const openSemesters = row.original.open_semesters ?? [];
if (openSemesters.length === 0) {
return (
<span className="text-xs text-muted-foreground">
Semua Tertutup
</span>
);
}
return (
<div className="flex flex-wrap gap-1">
{[...openSemesters]
.sort((a, b) => a - b)
.map((semester) => (
<Badge key={semester} variant="outline">
{semester}
</Badge>
))}
</div>
);
},
},
{
accessorKey: 'is_active',
header: () => <span className="block text-center">Status</span>,

View File

@ -182,7 +182,7 @@ export default function AcademicTermIndex({
<DataTable
columns={columns}
data={academicTerms.data}
searchKey="academic_year"
searchKey="name"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
@ -206,7 +206,7 @@ export default function AcademicTermIndex({
}}
title="Hapus Periode Akademik"
description={(academicTerm) =>
`Apakah Anda yakin ingin menghapus periode akademik "${academicTerm.academic_year}"? Tindakan ini tidak dapat dibatalkan.`
`Apakah Anda yakin ingin menghapus periode akademik "${academicTerm.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
@ -241,16 +241,15 @@ function CreateForm({
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="academic_year">
Tahun Ajaran{' '}
<span className="text-destructive">*</span>
<Label htmlFor="name">
Nama <span className="text-destructive">*</span>
</Label>
<Input
id="academic_year"
name="academic_year"
placeholder="Masukkan tahun ajaran, contoh: 2025/2026"
id="name"
name="name"
placeholder="Masukkan nama periode akademik"
/>
<InputError message={errors.academic_year} />
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>
@ -330,7 +329,6 @@ function CreateForm({
<Checkbox id="is_active" name="is_active" value="1" />
<Label htmlFor="is_active">Aktif</Label>
</div>
<OpenSemestersField idPrefix="create" />
</div>
)}
</FormDialog>
@ -370,17 +368,16 @@ function EditForm({
editing && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-academic_year">
Tahun Ajaran{' '}
<span className="text-destructive">*</span>
<Label htmlFor="edit-name">
Nama <span className="text-destructive">*</span>
</Label>
<Input
id="edit-academic_year"
name="academic_year"
placeholder="Masukkan tahun ajaran, contoh: 2025/2026"
defaultValue={editing.academic_year}
id="edit-name"
name="name"
placeholder="Masukkan nama periode akademik"
defaultValue={editing.name}
/>
<InputError message={errors.academic_year} />
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>
@ -470,49 +467,9 @@ function EditForm({
/>
<Label htmlFor="edit-is_active">Aktif</Label>
</div>
<OpenSemestersField
idPrefix="edit"
defaultValue={editing.open_semesters ?? []}
/>
</div>
)
}
</FormDialog>
);
}
function OpenSemestersField({
idPrefix,
defaultValue = [],
}: {
idPrefix: string;
defaultValue?: number[];
}) {
return (
<div className="grid gap-2">
<Label>Semester Dibuka untuk KRS</Label>
<p className="text-xs text-muted-foreground">
Kosongkan untuk menutup semua semester. Centang semester
tertentu untuk membuka pendaftaran KRS pada semester itu.
</p>
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3, 4, 5, 6, 7, 8].map((semester) => (
<div key={semester} className="flex items-center gap-2">
<Checkbox
id={`${idPrefix}-open-semester-${semester}`}
name="open_semesters[]"
value={semester}
defaultChecked={defaultValue.includes(semester)}
/>
<Label
htmlFor={`${idPrefix}-open-semester-${semester}`}
className="font-normal"
>
Semester {semester}
</Label>
</div>
))}
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { Course } from '@/types/course';
export type { Course } from '@/types/course';
@ -32,6 +33,11 @@ export function createCourseColumns(
header: () => <span>Nama Mata Kuliah</span>,
cell: ({ row }) => <span>{row.getValue('name') as string}</span>,
},
{
accessorKey: 'department.name',
header: () => <span>Jurusan</span>,
cell: ({ row }) => row.original.department?.name ?? '-',
},
{
accessorKey: 'credits',
header: () => <span className="block text-center">SKS</span>,
@ -45,6 +51,28 @@ export function createCourseColumns(
</div>
),
},
{
accessorKey: 'semester_number',
header: () => <span className="block text-center">Semester</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const semesterNumber = row.getValue('semester_number') as
number | null;
return (
<div className="flex justify-center">
{semesterNumber ? (
<Badge variant="secondary">{semesterNumber}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
);
},
},
];
if (canUpdate || canDelete) {

View File

@ -191,9 +191,6 @@ export default function CourseIndex({
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
groupBy={(course) =>
`${course.department?.name ?? 'Tanpa Jurusan'} - Semester ${course.semester_number ?? 'Tidak ditentukan'}`
}
toolbar={
<FilterDialog
fields={filterFields}

View File

@ -55,21 +55,6 @@ export function createDepartmentColumns(
);
},
},
{
id: 'kaprodi',
header: () => <span>Kaprodi</span>,
cell: ({ row }) => {
const leader = row.original.current_leader;
return leader ? (
<span>
{leader.lecturer?.user?.profile?.full_name ?? 'N/A'}
</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
];
if (canUpdate || canDelete) {

View File

@ -1,23 +1,13 @@
import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-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 {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@ -36,7 +26,7 @@ import {
update,
} from '@/routes/admin/master/departments';
import { DegreeLevels } from '@/types/department';
import type { Department, DepartmentLecturer } from '@/types/department';
import type { Department } from '@/types/department';
import { createDepartmentColumns } from './columns';
type Props = {
@ -47,19 +37,10 @@ type Props = {
per_page: number;
total: number;
};
lecturers: DepartmentLecturer[];
highlight?: number;
};
function lecturerLabel(lecturer: DepartmentLecturer): string {
return `${lecturer.user?.profile?.full_name ?? 'N/A'} - ${lecturer.lecturer_number}`;
}
export default function DepartmentIndex({
departments,
lecturers,
highlight,
}: Props) {
export default function DepartmentIndex({ departments, highlight }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [deleting, setDeleting] = useState<Department | null>(null);
@ -146,11 +127,7 @@ export default function DepartmentIndex({
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
lecturers={lecturers}
/>
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
<EditForm
key={editing?.id}
@ -161,7 +138,6 @@ export default function DepartmentIndex({
}
}}
editing={editing}
lecturers={lecturers}
/>
<DataTable
@ -193,113 +169,12 @@ export default function DepartmentIndex({
);
}
function KaprodiFields({
errors,
lecturers,
editing,
}: {
errors: Record<string, string>;
lecturers: DepartmentLecturer[];
editing?: Department | null;
}) {
const [lecturer, setLecturer] = useState<DepartmentLecturer | null>(
editing?.current_leader?.lecturer ?? null,
);
const [startedAt, setStartedAt] = useState<Date | undefined>(
editing?.current_leader
? new Date(editing.current_leader.started_at)
: undefined,
);
const availableLecturers = editing
? lecturers.filter((l) =>
l.departments.some((d) => d.id === editing.id),
)
: [];
return (
<>
<div className="grid gap-2">
<Label>
Kaprodi{' '}
{editing && (
<span className="text-destructive">*</span>
)}
</Label>
<input
type="hidden"
name="lecturer_id"
value={lecturer?.id ?? ''}
/>
<Combobox
items={availableLecturers}
value={lecturer}
disabled={!editing}
onValueChange={(value) => {
setLecturer(value);
if (value && !startedAt) {
setStartedAt(new Date());
}
}}
itemToStringLabel={(l) => lecturerLabel(l)}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxInput
placeholder={
editing
? 'Pilih kaprodi'
: 'Simpan jurusan terlebih dahulu'
}
disabled={!editing}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
{editing
? 'Belum ada dosen di jurusan ini.'
: 'Simpan jurusan terlebih dahulu, lalu tambahkan dosen ke jurusan ini.'}
</ComboboxEmpty>
<ComboboxList>
{(l: DepartmentLecturer) => (
<ComboboxItem key={l.id} value={l}>
{lecturerLabel(l)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.lecturer_id} />
</div>
{lecturer && (
<div className="grid gap-2">
<Label>
Mulai Menjabat{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="leadership_started_at"
value={
startedAt ? format(startedAt, 'yyyy-MM-dd') : ''
}
/>
<DatePicker value={startedAt} onChange={setStartedAt} />
<InputError message={errors.leadership_started_at} />
</div>
)}
</>
);
}
function CreateForm({
open,
onOpenChange,
lecturers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
lecturers: DepartmentLecturer[];
}) {
return (
<FormDialog
@ -347,7 +222,6 @@ function CreateForm({
</Select>
<InputError message={errors.degree_level} />
</div>
<KaprodiFields errors={errors} lecturers={lecturers} />
</div>
)}
</FormDialog>
@ -358,12 +232,10 @@ function EditForm({
open,
onOpenChange,
editing,
lecturers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Department | null;
lecturers: DepartmentLecturer[];
}) {
return (
<FormDialog
@ -425,11 +297,6 @@ function EditForm({
</Select>
<InputError message={errors.degree_level} />
</div>
<KaprodiFields
errors={errors}
lecturers={lecturers}
editing={editing}
/>
</div>
)
}

View File

@ -58,7 +58,7 @@ export function createAcademicAdvisingLogColumns(
},
{
accessorKey: 'lecturer.user.profile.full_name',
header: () => <span>Pembimbing Akademik</span>,
header: () => <span>Dosen Wali</span>,
cell: ({ row }) => {
const lecturer = row.original.lecturer;

View File

@ -258,7 +258,7 @@ function ViewDetailDialog({
<div className="grid gap-1">
<Label className="text-muted-foreground">
Pembimbing Akademik
Dosen Wali
</Label>
<p>
{log.lecturer?.user?.profile?.full_name ??
@ -338,8 +338,7 @@ function AdvisingLogFields({
<>
<div className="grid gap-2">
<Label>
Pembimbing Akademik{' '}
<span className="text-destructive">*</span>
Dosen Wali <span className="text-destructive">*</span>
</Label>
<input
type="hidden"

View File

@ -303,7 +303,7 @@ export default function Profile({
/>
</div>
<div className="grid gap-2">
<Label>Pembimbing Akademik</Label>
<Label>Dosen Wali</Label>
<Input
disabled
value={

View File

@ -35,7 +35,6 @@ type Lecturer = {
id: number;
lecturer_number: string;
user: { profile: { full_name: string } | null } | null;
departments: { id: number }[];
};
type Props = {
@ -46,15 +45,8 @@ type Props = {
export default function StudentCreate({ departments, lecturers }: Props) {
const [gender, setGender] = useState('');
const [birthDate, setBirthDate] = useState<Date | undefined>();
const [departmentId, setDepartmentId] = useState('');
const [advisor, setAdvisor] = useState<Lecturer | null>(null);
const availableAdvisors = departmentId
? lecturers.filter((l) =>
l.departments.some((d) => String(d.id) === departmentId),
)
: [];
return (
<>
<Head title="Tambah Mahasiswa" />
@ -287,15 +279,8 @@ export default function StudentCreate({ departments, lecturers }: Props) {
<input
type="hidden"
name="department_id"
value={departmentId}
/>
<Select
value={departmentId}
onValueChange={(value) => {
setDepartmentId(value);
setAdvisor(null);
}}
>
<Select name="department_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jurusan" />
</SelectTrigger>
@ -358,7 +343,7 @@ export default function StudentCreate({ departments, lecturers }: Props) {
</div>
<div className="grid gap-2">
<Label>
Pembimbing Akademik{' '}
Dosen Wali{' '}
<span className="text-red-500">
*
</span>
@ -369,9 +354,8 @@ export default function StudentCreate({ departments, lecturers }: Props) {
value={advisor?.id ?? ''}
/>
<Combobox
items={availableAdvisors}
items={lecturers}
value={advisor}
disabled={!departmentId}
onValueChange={setAdvisor}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
@ -381,18 +365,12 @@ export default function StudentCreate({ departments, lecturers }: Props) {
}
>
<ComboboxInput
disabled={!departmentId}
placeholder={
departmentId
? 'Pilih pembimbing akademik'
: 'Pilih jurusan terlebih dahulu'
}
placeholder="Pilih dosen wali"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Dosen tidak
ditemukan.
Dosen tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(lect: Lecturer) => (

View File

@ -35,7 +35,6 @@ type Lecturer = {
id: number;
lecturer_number: string;
user: { profile: { full_name: string } | null } | null;
departments: { id: number }[];
};
type User = {
@ -72,23 +71,12 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
? new Date(user.profile.birth_date)
: undefined,
);
const [departmentId, setDepartmentId] = useState(
user.student?.department_id
? String(user.student.department_id)
: '',
);
const [advisor, setAdvisor] = useState<Lecturer | null>(
lecturers.find(
(lect) => lect.id === user.student?.academic_advisor_id,
) ?? null,
);
const availableAdvisors = departmentId
? lecturers.filter((l) =>
l.departments.some((d) => String(d.id) === departmentId),
)
: [];
return (
<>
<Head title="Edit Mahasiswa" />
@ -330,17 +318,16 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
*
</span>
</Label>
<input
type="hidden"
name="department_id"
value={departmentId}
/>
<Select
value={departmentId}
onValueChange={(value) => {
setDepartmentId(value);
setAdvisor(null);
}}
name="department_id"
defaultValue={
user.student?.department_id
? String(
user.student
.department_id,
)
: undefined
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jurusan" />
@ -409,7 +396,7 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
</div>
<div className="grid gap-2">
<Label>
Pembimbing Akademik{' '}
Dosen Wali{' '}
<span className="text-red-500">
*
</span>
@ -420,9 +407,8 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
value={advisor?.id ?? ''}
/>
<Combobox
items={availableAdvisors}
items={lecturers}
value={advisor}
disabled={!departmentId}
onValueChange={setAdvisor}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
@ -432,18 +418,12 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
}
>
<ComboboxInput
disabled={!departmentId}
placeholder={
departmentId
? 'Pilih pembimbing akademik'
: 'Pilih jurusan terlebih dahulu'
}
placeholder="Pilih dosen wali"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Dosen tidak
ditemukan.
Dosen tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(lect: Lecturer) => (

View File

@ -0,0 +1,90 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Eye } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { show } from '@/routes/student/course-registrations';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { RegistrationStatus } from '@/types/course-registration';
import { RegistrationStatusLabels } from '@/types/course-registration';
export type SubmissionSummary = {
id: number;
status: RegistrationStatus;
signed_at: string;
academic_term: { id: number; name: string; semester: string } | null;
course_registrations_count: number;
};
export const submissionColumns: ColumnDef<SubmissionSummary>[] = [
{
id: 'academic_term',
header: () => <span>Periode Akademik</span>,
cell: ({ row }) => {
const term = row.original.academic_term;
return term ? formatAcademicTermLabel(term) : '-';
},
},
{
id: 'count',
header: () => <span className="block text-center">Jumlah</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => row.original.course_registrations_count,
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'w-[140px] text-center',
headerClassName: 'w-[140px] text-center',
},
cell: ({ row }) => {
const status = row.original.status;
return (
<div className="flex justify-center">
<Badge
variant={
status === 'approved'
? 'default'
: status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[status]}
</Badge>
</div>
);
},
},
{
id: 'signed_at',
header: () => <span>Tanggal</span>,
cell: ({ row }) =>
format(new Date(row.original.signed_at), 'd MMM yyyy, HH:mm'),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(row.original.id),
},
]}
/>
),
},
];

View File

@ -0,0 +1,259 @@
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, XCircle } from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { SignaturePad } from '@/components/signature-pad';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { index, store } from '@/routes/student/course-registrations';
import type { AcademicTerm } from '@/types/academic-term';
import { formatAcademicTermLabel } from '@/types/academic-term';
type Lecturer = {
user: { profile: { full_name: string } | null } | null;
};
type Course = {
id: number;
code: string;
name: string;
credits: number;
};
type CourseClass = {
id: number;
lecturer: Lecturer | null;
};
type Student = {
student_number: string;
current_semester: number;
department: { name: string } | null;
user: { profile: { full_name: string } | null } | null;
academic_advisor: {
user: { profile: { full_name: string } | null } | null;
} | null;
};
type AvailableCourse = {
course: Course;
course_class: CourseClass | null;
};
type Registration = {
id: number;
course_class: { id: number };
};
type Submission = {
status: 'submitted' | 'approved' | 'rejected';
rejection_reason: string | null;
course_registrations: Registration[];
};
type Props = {
student: Student;
academicTerm: AcademicTerm;
submission: Submission | null;
availableCourses: AvailableCourse[];
};
export default function CourseRegistrationCreate({
student,
academicTerm,
submission,
availableCourses,
}: Props) {
const [selected, setSelected] = useState<number[]>(() => {
if (submission && submission.course_registrations.length > 0) {
return submission.course_registrations.map(
(registration) => registration.course_class.id,
);
}
return availableCourses
.filter((item) => item.course_class)
.map((item) => item.course_class!.id);
});
function toggle(courseClassId: number, checked: boolean) {
setSelected((prev) =>
checked
? [...prev, courseClassId]
: prev.filter((id) => id !== courseClassId),
);
}
const totalCredits = availableCourses
.filter(
(item) =>
item.course_class && selected.includes(item.course_class.id),
)
.reduce((sum, item) => sum + item.course.credits, 0);
return (
<>
<Head title="Isi KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Isi Kartu Rencana Studi"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle>
{student.user?.profile?.full_name ?? '-'}
</CardTitle>
<CardDescription>
{student.student_number} &middot;{' '}
{student.department?.name ?? '-'} &middot; Semester{' '}
{student.current_semester}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-1 text-sm text-muted-foreground">
<p>
Periode Akademik:{' '}
{formatAcademicTermLabel(academicTerm)}
</p>
<p>
Dosen Pembimbing Akademik:{' '}
{student.academic_advisor?.user?.profile
?.full_name ?? '-'}
</p>
</CardContent>
</Card>
{submission?.status === 'rejected' && (
<Card className="border-destructive/50">
<CardContent className="pt-6 text-sm">
<p className="flex items-center gap-2 font-medium text-destructive">
<XCircle className="h-4 w-4" />
KRS Anda ditolak
</p>
<p className="mt-1 text-muted-foreground">
{submission.rejection_reason ??
'Tidak ada alasan yang diberikan.'}
</p>
<p className="mt-1 text-muted-foreground">
Silakan perbaiki pilihan mata kuliah Anda dan
ajukan kembali di bawah ini.
</p>
</CardContent>
</Card>
)}
<Form action={store()}>
{({ errors, processing }) => (
<Card>
<CardHeader>
<CardTitle>
Pilih Mata Kuliah Semester{' '}
{student.current_semester}
</CardTitle>
<CardDescription>
Total {totalCredits} SKS dipilih
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{availableCourses.length === 0 && (
<p className="text-sm text-muted-foreground">
Belum ada mata kuliah/kelas untuk
semester ini pada periode aktif.
</p>
)}
{availableCourses.map(
({ course, course_class }) => (
<label
key={course.id}
className="flex items-start gap-3 rounded-md border p-3 has-disabled:opacity-50"
>
<Checkbox
className="mt-0.5"
checked={
course_class
? selected.includes(
course_class.id,
)
: false
}
disabled={!course_class}
onCheckedChange={(checked) =>
course_class &&
toggle(
course_class.id,
checked === true,
)
}
/>
<div>
<p className="font-medium">
{course.code} -{' '}
{course.name}
</p>
<p className="text-xs text-muted-foreground">
{course.credits} SKS
&middot;{' '}
{course_class
? (course_class.lecturer
?.user?.profile
?.full_name ??
'Dosen belum ditentukan')
: 'Kelas belum dibuka'}
</p>
</div>
</label>
),
)}
{selected.map((courseClassId) => (
<input
key={courseClassId}
type="hidden"
name="course_class_ids[]"
value={courseClassId}
/>
))}
<InputError message={errors.course_class_ids} />
</CardContent>
<CardContent className="border-t pt-6">
<SignaturePad
name="signature"
error={errors.signature}
/>
</CardContent>
<CardFooter className="justify-end">
<Button
type="submit"
disabled={
processing || selected.length === 0
}
>
{submission?.status === 'rejected'
? 'Ajukan Ulang KRS'
: 'Tanda Tangan KRS'}
</Button>
</CardFooter>
</Card>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,129 @@
import { Head, Link } from '@inertiajs/react';
import { XCircle } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { formatRupiah } from '@/lib/currency';
import { create } from '@/routes/student/course-registrations';
import type { AcademicTerm } from '@/types/academic-term';
import type { RegistrationStatus } from '@/types/course-registration';
import type { SubmissionSummary } from './columns';
import { submissionColumns } from './columns';
type PaymentStatus = {
has_invoice: boolean;
is_paid: boolean;
amount_due: number | null;
paid_total: number | null;
};
type ActiveSubmission = {
status: RegistrationStatus;
rejection_reason: string | null;
};
type Props = {
academicTerm: AcademicTerm | null;
payment: PaymentStatus | null;
canSubmit: boolean;
activeSubmission: ActiveSubmission | null;
submissions: SubmissionSummary[];
};
export default function CourseRegistrationIndex({
academicTerm,
payment,
canSubmit,
activeSubmission,
submissions,
}: Props) {
return (
<>
<Head title="KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Kartu Rencana Studi"
description={
academicTerm && (
<p className="mt-1 text-sm text-muted-foreground">
Periode aktif: {academicTerm.name} (
{academicTerm.semester === 'odd'
? 'Ganjil'
: 'Genap'}
)
</p>
)
}
/>
{!academicTerm && (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Belum ada periode akademik yang aktif. Silakan
hubungi admin.
</CardContent>
</Card>
)}
{academicTerm && payment && !payment.is_paid && (
<Card className="border-destructive/50">
<CardContent className="pt-6 text-sm">
<p className="font-medium text-destructive">
Anda belum bisa mengisi KRS periode ini.
</p>
<p className="mt-1 text-muted-foreground">
{payment.has_invoice
? `Tagihan periode ini belum lunas (terbayar ${formatRupiah(payment.paid_total ?? 0)} dari ${formatRupiah(payment.amount_due ?? 0)}).`
: 'Tagihan untuk periode ini belum diterbitkan. Silakan hubungi staf keuangan.'}
</p>
</CardContent>
</Card>
)}
{academicTerm && payment?.is_paid && canSubmit && (
<Card
className={
activeSubmission?.status === 'rejected'
? 'border-destructive/50'
: undefined
}
>
<CardContent className="flex flex-col gap-3 pt-6 sm:flex-row sm:items-center sm:justify-between">
<div>
{activeSubmission?.status === 'rejected' ? (
<>
<p className="flex items-center gap-2 text-sm font-medium text-destructive">
<XCircle className="h-4 w-4" />
KRS Anda untuk periode ini ditolak
</p>
<p className="mt-1 text-sm text-muted-foreground">
{activeSubmission.rejection_reason ??
'Tidak ada alasan yang diberikan.'}{' '}
Silakan perbaiki dan ajukan kembali.
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
Anda belum mengisi KRS untuk periode
aktif ini.
</p>
)}
</div>
<Button asChild>
<Link href={create.url()}>
{activeSubmission?.status === 'rejected'
? 'Ajukan Ulang KRS'
: 'Isi KRS'}
</Link>
</Button>
</CardContent>
</Card>
)}
<DataTable columns={submissionColumns} data={submissions} />
</div>
</>
);
}

View File

@ -0,0 +1,180 @@
import { Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { index } from '@/routes/student/course-registrations';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { CourseRegistrationSubmission } from '@/types/course-registration';
import { RegistrationStatusLabels } from '@/types/course-registration';
type Props = {
submission: CourseRegistrationSubmission;
};
export default function CourseRegistrationShow({ submission }: Props) {
return (
<>
<Head title="Detail KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Kartu Rencana Studi"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>
{submission.academic_term
? formatAcademicTermLabel(
submission.academic_term,
)
: '-'}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
Ditandatangani pada{' '}
{format(
new Date(submission.signed_at),
'd MMM yyyy, HH:mm',
)}
</p>
</div>
<Badge
variant={
submission.status === 'approved'
? 'default'
: submission.status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[submission.status]}
</Badge>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Tanda Tangan
</CardTitle>
</CardHeader>
<CardContent>
{submission.signature_url ? (
<div className="w-fit rounded-md border bg-white p-2">
<img
src={submission.signature_url}
alt="Tanda tangan"
className="h-24"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
Tidak ada tanda tangan.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Mata Kuliah (
{submission.course_registrations.length})
</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.course_registrations.map((registration) => (
<div
key={registration.id}
className="rounded-md border p-3"
>
<p className="font-medium">
{registration.course_class?.course?.code ??
'-'}{' '}
-{' '}
{registration.course_class?.course?.name ??
'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{registration.course_class?.course
?.credits ?? '-'}{' '}
SKS &middot;{' '}
{registration.course_class?.lecturer?.user
?.profile?.full_name ??
'Dosen belum ditentukan'}
</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Riwayat</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.logs.map((log, index) => (
<LogBubble
key={log.id}
log={log}
isResubmission={
log.status === 'submitted' &&
submission.logs
.slice(0, index)
.some((l) => l.status === 'submitted')
}
/>
))}
</CardContent>
</Card>
</div>
</>
);
}
function LogBubble({
log,
isResubmission,
}: {
log: CourseRegistrationSubmission['logs'][number];
isResubmission: boolean;
}) {
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
const message =
log.status === 'approved'
? `${actorName} menyetujui KRS ini.`
: log.status === 'rejected'
? `${actorName} menolak KRS ini dengan alasan: "${log.reason}"`
: isResubmission
? `${actorName} mengajukan ulang KRS.`
: `${actorName} mengajukan KRS.`;
const colorClasses =
log.status === 'approved'
? 'border-primary/30 bg-primary/10'
: log.status === 'rejected'
? 'border-destructive/30 bg-destructive/10'
: 'border-border bg-muted';
return (
<div className={`rounded-lg border px-3 py-2 ${colorClasses}`}>
<p className="text-sm">{message}</p>
<p className="mt-1 text-xs text-muted-foreground">
{format(new Date(log.created_at), 'd MMM yyyy, HH:mm')}
</p>
</div>
);
}

View File

@ -10,23 +10,22 @@ export const SemesterLabels: Record<Semester, string> = {
export type AcademicTerm = {
id: number;
academic_year: string;
name: string;
semester: Semester;
start_date: string;
formatted_start_date: string;
end_date: string;
formatted_end_date: string;
is_active: boolean;
open_semesters: number[] | null;
created_at: string;
updated_at: string;
};
export function formatAcademicTermLabel(term: {
academic_year: string;
name: string;
semester: string;
}): string {
const label = SemesterLabels[term.semester as Semester];
return label ? `${term.academic_year} (${label})` : term.academic_year;
return label ? `${term.name} (${label})` : term.name;
}

View File

@ -16,7 +16,6 @@ export type CourseClass = {
code: string;
name: string;
department_id: number;
semester_number: number | null;
} | null;
lecturer_id: number;
lecturer: {
@ -26,11 +25,7 @@ export type CourseClass = {
user: { profile: { full_name: string } | null } | null;
} | null;
academic_term_id: number;
academic_term: {
id: number;
academic_year: string;
semester: string;
} | null;
academic_term: { id: number; name: string; semester: string } | null;
method: ClassMethod | null;
enrollments_count: number;
created_at: string;

View File

@ -13,84 +13,65 @@ export const RegistrationStatusLabels: Record<RegistrationStatus, string> = {
};
export type CourseRegistrationStudent = {
id: number;
student_number: string;
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 CourseRegistrationSubmissionStudent = {
id: number;
student_number: string;
current_semester: number;
department: {
id: number;
name: string;
current_leader: {
lecturer: CourseRegistrationSubmissionLecturer | null;
} | null;
} | null;
department: { id: number; name: string } | null;
user: { profile: { full_name: string } | null } | null;
academic_advisor: CourseRegistrationSubmissionLecturer | null;
};
export type CourseRegistrationSubmissionEntry = {
export type CourseRegistrationCourseClass = {
id: number;
is_registered: boolean;
academic_term_id: number;
course: {
id: number;
code: string;
name: string;
credits: number;
semester_number: number | null;
};
course_class: {
id: number;
department_id: number;
credits?: number;
} | null;
lecturer?: {
user: { profile: { full_name: string } | null } | null;
} | null;
};
export type CourseRegistration = {
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;
};
export type CourseRegistrationSubmissionLog = {
id: number;
status: RegistrationStatus;
reason: string | null;
actor: { profile: { full_name: string } | null } | null;
actor: CourseRegistrationActor | null;
created_at: string;
};
export type CourseRegistrationSubmission = {
id: number;
student_id: number;
student: CourseRegistrationStudent | null;
academic_term_id: number;
academic_term: { id: number; name: string; semester: string } | null;
status: RegistrationStatus;
rejection_reason: string | null;
signed_at: string;
signature_url: string | null;
kaprodi_signature_url: string | null;
advisor_signature_url: string | null;
has_registrations: boolean;
student: CourseRegistrationSubmissionStudent | null;
academic_term: {
id: number;
academic_year: string;
semester: string;
} | null;
course_registrations: CourseRegistrationSubmissionEntry[];
rejection_reason: string | null;
reviewed_by: number | null;
reviewer: CourseRegistrationActor | null;
reviewed_at: string | null;
course_registrations: CourseRegistration[];
logs: CourseRegistrationSubmissionLog[];
};
export function semesterKey(semester: number | null): string {
return semester === null ? 'lainnya' : String(semester);
}

View File

@ -2,27 +2,11 @@ export const DegreeLevels = ['D3', 'D4', 'S1', 'S2', 'S3'] as const;
export type DegreeLevel = (typeof DegreeLevels)[number];
export type DepartmentLecturer = {
id: number;
lecturer_number: string;
user: { profile: { full_name: string } | null } | null;
departments: { id: number }[];
};
export type DepartmentLeadership = {
id: number;
lecturer_id: number;
started_at: string;
ended_at: string | null;
lecturer: DepartmentLecturer | null;
};
export type Department = {
id: number;
code: string;
name: string;
degree_level: DegreeLevel | null;
current_leader: DepartmentLeadership | null;
created_at: string;
updated_at: string;
};

View File

@ -11,11 +11,7 @@ export type TuitionInvoice = {
student_id: number;
student: TuitionInvoiceStudent | null;
academic_term_id: number;
academic_term: {
id: number;
academic_year: string;
semester: string;
} | null;
academic_term: { id: number; name: string; semester: string } | null;
amount_due: string;
due_date: string | null;
paid_total: string | null;

View File

@ -98,14 +98,15 @@
});
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
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-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::resource('course-classes', CourseClassController::class)
@ -115,12 +116,9 @@
->middlewareFor(['update'], 'permission:update-course-classes')
->middlewareFor(['destroy'], 'permission:delete-course-classes');
Route::post('course-classes/duplicate', [CourseClassController::class, 'duplicate'])
->name('course-classes.duplicate')
->middleware('permission:create-course-classes');
Route::prefix('course-classes/{course_class}/enrollments')->name('course-classes.enrollments.')->group(function () {
Route::get('/', [ClassEnrollmentController::class, 'index'])->name('index')->middleware('permission:view-course-class-enrollments');
Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store')->middleware('permission:create-course-class-enrollments');
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy')->middleware('permission:delete-course-class-enrollments');
});
@ -132,6 +130,15 @@
});
});
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'])