Compare commits

..

24 Commits

Author SHA1 Message Date
Yoga Pangestu
8528a3258d feat: enhance sorting in CourseService for improved course selection 2026-09-02 18:24:08 +07:00
Yoga Pangestu
a94ebfb77d feat: add Skripsi II course to CourseSeeder for semester 8 2026-09-02 18:23:28 +07:00
Yoga Pangestu
378ddebac9 feat: refactor DatabaseSeeder and UserSeeder for improved structure and local environment handling 2026-09-02 18:15:21 +07:00
Yoga Pangestu
cdd81caf22 feat: update CreateForm and EditForm to filter courses and lecturers based on selected lecturer, enhancing user experience 2026-09-02 18:04:14 +07:00
Yoga Pangestu
1b68cf6125 feat: enhance CourseClass pagination and UI by adding academic term and semester number details 2026-09-02 18:01:19 +07:00
Yoga Pangestu
c57787eb20 feat: add semester parity validation for course duplication and enhance UI for target term selection 2026-09-02 17:53:40 +07:00
Yoga Pangestu
5613078c00 feat: add course term parity validation for course selection in CreateForm and EditForm 2026-09-02 17:41:35 +07:00
Yoga Pangestu
c1cee7cd45 feat: add duplicate course classes functionality with validation and UI integration 2026-09-02 17:39:58 +07:00
Yoga Pangestu
0d27a9e706 feat: remove ClassEnrollmentRequest and related enrollment logic, simplifying class enrollment management 2026-09-02 16:45:59 +07:00
Yoga Pangestu
65618bf961 feat: enhance leadership management by adding role assignment and revocation for lecturers 2026-09-02 15:42:19 +07:00
Yoga Pangestu
03c3fc61af feat: implement bulk creation of course classes and enhance validation for lecturer assignment 2026-09-02 15:34:55 +07:00
Yoga Pangestu
584056e379 feat: enhance course class management by adding course class assignments and improving course selection UI 2026-09-02 15:07:41 +07:00
Yoga Pangestu
28a25fabfd feat: add student current semester handling in course registration logic 2026-09-02 14:38:08 +07:00
Yoga Pangestu
9892f60e5e feat: refactor course registration logic to use department-specific courses and improve submission payload structure 2026-09-02 13:59:02 +07:00
Yoga Pangestu
86863ff2f8 feat: enhance course pagination and grouping by department and semester 2026-09-02 13:38:40 +07:00
Yoga Pangestu
9823b879d8 refactor: remove redundant date validation logic from AcademicTermRequest 2026-09-02 13:26:55 +07:00
Yoga Pangestu
ded7217dbc feat: rename 'name' to 'academic_year' in AcademicTerm and related components for consistency 2026-09-02 13:20:18 +07:00
Yoga Pangestu
6c55fb5f36 fix: adjust padding in FormDialog component for better layout 2026-09-02 12:43:09 +07:00
Yoga Pangestu
0327b07669 feat: set default maxDate in DatePicker component 2026-09-02 11:56:00 +07:00
Yoga Pangestu
692b84cc1b feat: add open semesters badge to academic terms and remove course registration pages
- Added a new column to display open semesters in the academic terms table with badges.
- Removed course registration related pages and components including index, create, and show.
- Updated course registration types to reflect changes in the data structure.
- Refactored admin routes for course registrations to streamline submission handling.
2026-09-01 21:39:46 +07:00
Yoga Pangestu
6621757713 feat: add department selection and advisor filtering in student create and edit forms 2026-09-01 20:59:56 +07:00
Yoga Pangestu
a7aec7245b feat: rename "Dosen Wali" to "Pembimbing Akademik" in various components for consistency 2026-09-01 20:51:50 +07:00
Yoga Pangestu
2bb892ef50 feat: add lecturer management to Department module, including leadership fields and display in UI 2026-09-01 20:49:04 +07:00
Yoga Pangestu
10c3e42228 feat: add open_semesters field to AcademicTerm model and request, update related services and migration 2026-09-01 14:27:02 +07:00
66 changed files with 2620 additions and 2330 deletions

View File

@ -3,7 +3,6 @@
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;
@ -24,17 +23,9 @@ 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,6 +4,7 @@
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;
@ -34,15 +35,21 @@ 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
{
$this->service->create($request->validated());
$created = $this->service->createMany($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil ditambahkan.']);
Inertia::flash('toast', [
'type' => 'success',
'message' => $created->count() > 1
? "{$created->count()} kelas mata kuliah berhasil ditambahkan."
: 'Kelas mata kuliah berhasil ditambahkan.',
]);
return to_route('admin.manage.course-classes.index');
}
@ -62,4 +69,24 @@ 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,17 +3,16 @@
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\RejectCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\SaveCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\SignCourseRegistrationRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\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;
@ -21,109 +20,154 @@ class CourseRegistrationController extends Controller
{
public function __construct(
private readonly CourseRegistrationService $service,
private readonly StudentService $studentService,
private readonly AcademicTermService $academicTermService,
private readonly CourseClassService $courseClassService,
) {}
public function index(PaginatedRequest $request): Response
public function index(PaginatedRequest $request): Response|RedirectResponse
{
$user = auth()->user();
if ($user->hasRole('mahasiswa')) {
return to_route('admin.manage.course-registrations.show', $user->student->id);
}
$isKaprodi = $user->hasRole('kaprodi');
return Inertia::render('admin/manage/course-registrations/index', [
'departments' => $this->service->departmentSummary(),
'registrations' => $this->service->paginated(
...$request->validatedWithDefaults(),
status: $request->validated('status'),
academicTermId: $request->validated('academic_term_id'),
departmentId: $request->validated('department_id'),
advisorLecturerId: ! $isKaprodi && $user->hasRole('dosen') ? $user->lecturer?->id : null,
ledDepartmentIds: $isKaprodi ? $user->ledDepartmentIds() : null,
),
'students' => $this->studentService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClasses' => $this->courseClassService->getAllForSelect(),
'filters' => $request->only(['status', 'academic_term_id']),
'filters' => $request->only(['department_id']),
]);
}
public function mine(Request $request): Response
public function show(Student $student): Response
{
$student = $this->currentStudent($request);
$user = auth()->user();
$isStudent = $user->hasRole('mahasiswa');
$term = $this->academicTermService->getActive();
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
$activeSubmission = $term ? $this->service->currentSubmission($student, $term) : null;
abort_if($isStudent && $user->student->id !== $student->id, 403);
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
return Inertia::render('student/course-registrations/index', [
'academicTerm' => $term,
'payment' => $payment,
'canSubmit' => $this->service->canSubmit($activeSubmission),
'activeSubmission' => $activeSubmission,
'submissions' => $this->service->submissionsFor($student),
]);
}
$backHref = $isStudent
? null
: route('admin.manage.course-registrations.index');
public function create(Request $request): Response|RedirectResponse
{
$student = $this->currentStudent($request);
$canReview = ! $isStudent
&& $user->can('approve-course-registrations')
&& $user->canReviewCourseRegistrationOf($student);
$student->load(['user.profile', 'department', 'academicAdvisor.user.profile']);
$canSignAsKaprodi = $user->isKaprodiOf($student);
$canSignAsAdvisor = $user->isAdvisorOf($student);
$term = $this->academicTermService->getActive();
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
$submission = $term ? $this->service->currentSubmission($student, $term) : null;
$activeTerm = $this->academicTermService->getActive();
$allCourseClasses = $activeTerm
? $this->service->availableCourseClasses($student->department_id, $activeTerm->id)
: collect();
if (! $term || ! $payment['is_paid'] || ! $this->service->canSubmit($submission)) {
return to_route('student.course-registrations.index');
}
$departmentCourses = $this->service->departmentCourses($student->department_id);
return Inertia::render('student/course-registrations/create', [
'student' => $student,
'academicTerm' => $term,
'submission' => $submission,
'availableCourses' => $this->service->availableCourseClasses($student, $term),
]);
}
$semesters = $departmentCourses
->map(fn (Course $course) => $course->semester_number)
->unique()
->sort()
->values();
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),
]);
}
$submissions = $activeTerm
? $semesters->mapWithKeys(fn (?int $semesterNumber) => [
CourseRegistrationSubmission::semesterKey($semesterNumber) => $this->service->buildSubmissionPayload(
$student,
$activeTerm,
$semesterNumber,
$departmentCourses,
$allCourseClasses,
),
])
: collect();
return Inertia::render('admin/manage/course-registrations/show', [
'submission' => $this->service->withDetails($submission),
'submissions' => $submissions,
'semesters' => $semesters->values(),
'openSemesters' => $activeTerm?->open_semesters ?? [],
'studentCurrentSemester' => $student->current_semester,
'hasActiveTerm' => $activeTerm !== null,
'backHref' => $backHref,
'canReview' => $canReview,
'canSignAsKaprodi' => $canSignAsKaprodi,
'canSignAsAdvisor' => $canSignAsAdvisor,
]);
}
public function store(CourseRegistrationRequest $request): RedirectResponse
public function save(Student $student, string $semester, SaveCourseRegistrationRequest $request): RedirectResponse
{
if ($request->routeIs('student.*')) {
$student = $this->currentStudent($request);
$term = $this->academicTermService->getActive();
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
$submission = $this->service->sign(
$student,
$term,
$request->validated('course_class_ids'),
$request->file('signature'),
);
$activeTerm = $this->academicTermService->getActive();
Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditandatangani.']);
abort_if($activeTerm === null, 422);
return to_route('student.course-registrations.show', $submission);
}
$selectableIds = $this->service->selectableCourseClassIds(
$student->department_id,
$activeTerm->id,
$semesterNumber,
$activeTerm->open_semesters ?? [],
$student->current_semester,
);
$this->service->create($request->validated());
$selectedIds = array_values(array_intersect(
$request->validated('course_class_ids') ?? [],
$selectableIds,
));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']);
$submission = $this->service->findOrCreateForStudent($student, $activeTerm, $semesterNumber);
return to_route('admin.manage.course-registrations.index');
$this->service->saveRegistrations(
$submission,
$selectedIds,
$activeTerm->id,
$student->id,
$request->file('signature'),
);
Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disimpan.']);
return to_route('admin.manage.course-registrations.show', $student->id);
}
public function signAsKaprodi(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse
{
abort_unless(auth()->user()->isKaprodiOf($student), 403);
$submission = $this->service->findOrCreateForSemester($student, $semester);
$this->service->signAsKaprodi($submission, $request->file('signature'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']);
return to_route('admin.manage.course-registrations.show', $student->id);
}
public function signAsAdvisor(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse
{
abort_unless(auth()->user()->isAdvisorOf($student), 403);
$submission = $this->service->findOrCreateForSemester($student, $semester);
$this->service->signAsAdvisor($submission, $request->file('signature'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']);
return to_route('admin.manage.course-registrations.show', $student->id);
}
public function approve(CourseRegistrationSubmission $submission): RedirectResponse
{
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
$this->service->approve($submission);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disetujui.'])->back();
@ -131,17 +175,10 @@ public function approve(CourseRegistrationSubmission $submission): RedirectRespo
public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
{
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
$this->service->reject($submission, $request->validated('reason'));
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
}
private function currentStudent(Request $request): Student
{
$student = $request->user()->student;
abort_if(! $student, 403);
return $student;
}
}

View File

@ -7,6 +7,7 @@
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;
@ -15,12 +16,14 @@ 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

@ -1,29 +0,0 @@
<?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,7 +3,10 @@
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;
@ -16,10 +19,73 @@ 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')],
'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,
],
'lecturer_id' => [
'required',
'integer',

View File

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

View File

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

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

View File

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

View File

@ -16,13 +16,13 @@ public function authorize(): bool
public function rules(): array
{
return [
'name' => [
'academic_year' => [
'required',
'string',
'max:20',
function ($attribute, $value, $fail) {
if (! preg_match('/^(\d{4})\/(\d{4})$/', $value, $matches)) {
$fail('Format name harus YYYY/YYYY, contoh: 2025/2026.');
$fail('Format tahun ajaran harus YYYY/YYYY, contoh: 2025/2026.');
return;
}
@ -40,45 +40,21 @@ function ($attribute, $value, $fail) {
'string',
Rule::in(Semester::values()),
Rule::unique('academic_terms')->where(function ($query) {
return $query->where('name', $this->input('name'));
return $query->where('academic_year', $this->input('academic_year'));
})->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,6 +23,14 @@ 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,7 +73,8 @@ public function rules(): array
'academic_advisor_id' => [
'required',
'integer',
Rule::exists('lecturers', 'id'),
Rule::exists('lecturer_department', 'lecturer_id')
->where('department_id', $this->input('department_id')),
],
'status' => [
'nullable',

View File

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

View File

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

View File

@ -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,name,semester,start_date,end_date'])
->with(['student.user.profile', 'student.department', 'academicTerm:id,academic_year,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,7 +4,6 @@
use App\Models\ClassEnrollment;
use App\Models\CourseClass;
use App\Models\Student;
use Illuminate\Database\Eloquent\Collection;
class ClassEnrollmentService
@ -17,25 +16,6 @@ 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,6 +5,7 @@
use App\Models\CourseClass;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class CourseClassService
{
@ -18,19 +19,32 @@ public function getAllForSelect(): Collection
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
{
return CourseClass::query()
->select(['id', 'course_id', 'lecturer_id', 'academic_term_id', 'method'])
->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')
->withCount('enrollments')
->with(['course:id,code,name,department_id', 'lecturer.user.profile', 'academicTerm:id,name,semester,start_date,end_date'])
->with(['course:id,code,name,department_id,semester_number', 'lecturer.user.profile', 'academicTerm:id,academic_year,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('academic_term_id', $academicTermId))
->when($method, fn ($q) => $q->where('method', $method))
->latest()
->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')
->paginate($perPage);
}
public function create(array $data): CourseClass
/**
* @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
{
return CourseClass::create($data);
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']));
}
public function update(CourseClass $courseClass, array $data): CourseClass
@ -48,4 +62,47 @@ 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,14 +3,16 @@
namespace App\Services\Admin\Manage;
use App\Enums\RegistrationStatus;
use App\Enums\StudentStatus;
use App\Models\AcademicTerm;
use App\Models\ClassEnrollment;
use App\Models\Course;
use App\Models\CourseClass;
use App\Models\CourseRegistration;
use App\Models\CourseRegistrationSubmission;
use App\Models\Department;
use App\Models\Student;
use App\Models\TuitionInvoice;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
@ -18,70 +20,231 @@
class CourseRegistrationService
{
public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator
public function __construct(
private readonly AcademicTermService $academicTermService,
) {}
/**
* @return Collection<int, Department>
*/
public function departmentSummary(): Collection
{
return CourseRegistrationSubmission::query()
->select(['id', 'student_id', 'academic_term_id', 'status', 'signed_at', 'rejection_reason', 'reviewed_by', 'reviewed_at'])
->with([
'student.user.profile',
'student.department',
'academicTerm:id,name,semester,start_date,end_date',
'reviewer.profile',
'courseRegistrations.courseClass.course:id,code,name',
'logs' => fn ($q) => $q->latest(),
'logs.actor.profile',
])
->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($status, fn ($q) => $q->where('status', $status))
->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
->latest()
->paginate($perPage);
return Department::query()
->select('id', 'name')
->orderBy('name')
->get();
}
/**
* Admin-created registration: additively attach course classes to a
* student's submission for a term, without disturbing an existing
* submission's review status.
*
* @param array{student_id: int, academic_term_id: int, course_class_ids: array<int, int>} $data
* @param array<int, int>|null $ledDepartmentIds Restricts results to a kaprodi's own department(s).
*/
public function create(array $data): CourseRegistrationSubmission
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $advisorLecturerId = null, ?array $ledDepartmentIds = null): LengthAwarePaginator
{
return DB::transaction(function () use ($data) {
$submission = CourseRegistrationSubmission::firstOrCreate(
[
'student_id' => $data['student_id'],
'academic_term_id' => $data['academic_term_id'],
],
[
'status' => RegistrationStatus::Submitted,
'signed_at' => now(),
],
);
$paginator = Student::query()
->select(['id', 'user_id', 'student_number', 'department_id'])
->where('status', StudentStatus::Active)
->when($ledDepartmentIds !== null, fn ($q) => $q->whereIn('department_id', $ledDepartmentIds))
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
->when($advisorLecturerId, fn ($q) => $q->where('academic_advisor_id', $advisorLecturerId))
->when($search, fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))
->with(['user.profile', 'department:id,name'])
->orderBy('student_number')
->paginate($perPage);
if ($submission->wasRecentlyCreated) {
$submission->logs()->create([
'status' => RegistrationStatus::Submitted,
'actor_id' => auth()->id(),
]);
}
return $paginator->through(fn (Student $student) => [
'student_id' => $student->id,
'student' => $student,
]);
}
foreach ($data['course_class_ids'] as $courseClassId) {
public function findOrCreateForStudent(Student $student, AcademicTerm $academicTerm, ?int $semesterNumber): CourseRegistrationSubmission
{
return CourseRegistrationSubmission::firstOrCreate(
[
'student_id' => $student->id,
'academic_term_id' => $academicTerm->id,
'semester_number' => $semesterNumber,
],
[
'status' => RegistrationStatus::Submitted,
'signed_at' => now(),
],
);
}
public function findOrCreateForSemester(Student $student, string $semester): CourseRegistrationSubmission
{
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
$activeTerm = $this->academicTermService->getActive();
abort_if($activeTerm === null, 422);
return $this->findOrCreateForStudent($student, $activeTerm, $semesterNumber);
}
/**
* @return array<string, mixed>
*/
public function buildSubmissionPayload(Student $student, AcademicTerm $activeTerm, ?int $semesterNumber, Collection $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.
*
* @return Collection<int, Course>
*/
public function departmentCourses(int $departmentId): Collection
{
return Course::query()
->where('department_id', $departmentId)
->orderBy('semester_number')
->orderBy('name')
->get(['id', 'code', 'name', 'credits', 'semester_number']);
}
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',
]);
}
/**
* @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) {
CourseRegistration::create([
'student_id' => $data['student_id'],
'academic_term_id' => $data['academic_term_id'],
'student_id' => $studentId,
'academic_term_id' => $academicTermId,
'course_class_id' => $courseClassId,
'submission_id' => $submission->id,
]);
}
$submission->update([
'status' => RegistrationStatus::Submitted,
'rejection_reason' => null,
'signed_at' => now(),
]);
if ($signature) {
$submission->addMedia($signature)->toMediaCollection('signature');
}
$submission->logs()->create([
'status' => RegistrationStatus::Submitted,
'actor_id' => auth()->id(),
]);
return $submission;
});
}
public function signAsKaprodi(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
{
$this->assertSignable($submission);
$submission->addMedia($signature)->toMediaCollection('kaprodi_signature');
return $submission;
}
public function signAsAdvisor(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
{
$this->assertSignable($submission);
$submission->addMedia($signature)->toMediaCollection('advisor_signature');
return $submission;
}
private function assertSignable(CourseRegistrationSubmission $submission): void
{
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
abort_unless($submission->courseRegistrations()->exists(), 403);
}
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
{
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
abort_if($submission->kaprodi_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.');
abort_if($submission->advisor_signature_url === null, 422, 'Silakan tanda tangani KRS ini terlebih dahulu.');
return DB::transaction(function () use ($submission) {
$submission->update([
'status' => RegistrationStatus::Approved,
@ -113,6 +276,8 @@ public function approve(CourseRegistrationSubmission $submission): CourseRegistr
public function reject(CourseRegistrationSubmission $submission, string $reason): CourseRegistrationSubmission
{
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
return DB::transaction(function () use ($submission, $reason) {
$submission->update([
'status' => RegistrationStatus::Rejected,
@ -130,164 +295,4 @@ public function reject(CourseRegistrationSubmission $submission, string $reason)
return $submission;
});
}
/**
* @return Collection<int, array{course: Course, course_class: ?CourseClass}>
*/
public function availableCourseClasses(Student $student, AcademicTerm $term): Collection
{
$courses = Course::query()
->where('department_id', $student->department_id)
->where('semester_number', $student->current_semester)
->orderBy('name')
->get(['id', 'code', 'name', 'credits']);
$classes = CourseClass::query()
->whereIn('course_id', $courses->pluck('id'))
->where('academic_term_id', $term->id)
->with('lecturer.user.profile')
->get()
->keyBy('course_id');
return $courses->map(fn (Course $course) => [
'course' => $course,
'course_class' => $classes->get($course->id),
])->values();
}
/**
* @return array{has_invoice: bool, is_paid: bool, amount_due: ?float, paid_total: ?float}
*/
public function paymentStatus(Student $student, AcademicTerm $term): array
{
$invoice = TuitionInvoice::query()
->where('student_id', $student->id)
->where('academic_term_id', $term->id)
->withSum('payments as paid_total', 'amount_paid')
->first();
if (! $invoice) {
return [
'has_invoice' => false,
'is_paid' => false,
'amount_due' => null,
'paid_total' => null,
];
}
$paidTotal = (float) ($invoice->paid_total ?? 0);
$amountDue = (float) $invoice->amount_due;
return [
'has_invoice' => true,
'is_paid' => $paidTotal >= $amountDue,
'amount_due' => $amountDue,
'paid_total' => $paidTotal,
];
}
public function currentSubmission(Student $student, AcademicTerm $term): ?CourseRegistrationSubmission
{
return CourseRegistrationSubmission::query()
->where('student_id', $student->id)
->where('academic_term_id', $term->id)
->with([
'courseRegistrations.courseClass.course',
'courseRegistrations.courseClass.lecturer.user.profile',
'logs' => fn ($q) => $q->latest(),
'logs.actor.profile',
])
->first();
}
/**
* @return Collection<int, CourseRegistrationSubmission>
*/
public function submissionsFor(Student $student): Collection
{
return CourseRegistrationSubmission::query()
->where('student_id', $student->id)
->withCount('courseRegistrations')
->with('academicTerm:id,name,semester,start_date,end_date')
->latest()
->get();
}
public function withDetails(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
{
return $submission->load([
'student.user.profile',
'student.department',
'academicTerm:id,name,semester,start_date,end_date',
'reviewer.profile',
'courseRegistrations.courseClass.course',
'courseRegistrations.courseClass.lecturer.user.profile',
'logs' => fn ($q) => $q->oldest(),
'logs.actor.profile',
]);
}
public function canSubmit(?CourseRegistrationSubmission $submission): bool
{
return $submission === null || $submission->status === RegistrationStatus::Rejected;
}
/**
* @return array<int, int>
*/
public function allowedCourseClassIds(Student $student, AcademicTerm $term): array
{
$courseIds = Course::query()
->where('department_id', $student->department_id)
->where('semester_number', $student->current_semester)
->pluck('id');
return CourseClass::query()
->whereIn('course_id', $courseIds)
->where('academic_term_id', $term->id)
->pluck('id')
->all();
}
/**
* Student self-service sign-off: replaces the submission's course
* selection wholesale and attaches the captured signature.
*
* @param array<int, int> $courseClassIds
*/
public function sign(Student $student, AcademicTerm $term, array $courseClassIds, UploadedFile $signatureFile): CourseRegistrationSubmission
{
return DB::transaction(function () use ($student, $term, $courseClassIds, $signatureFile) {
$submission = CourseRegistrationSubmission::updateOrCreate(
['student_id' => $student->id, 'academic_term_id' => $term->id],
[
'status' => RegistrationStatus::Submitted,
'signed_at' => now(),
'rejection_reason' => null,
'reviewed_by' => null,
'reviewed_at' => null,
],
);
$submission->courseRegistrations()->delete();
foreach ($courseClassIds as $courseClassId) {
CourseRegistration::create([
'student_id' => $student->id,
'academic_term_id' => $term->id,
'course_class_id' => $courseClassId,
'submission_id' => $submission->id,
]);
}
$submission->addMedia($signatureFile)->toMediaCollection('signature');
$submission->logs()->create([
'status' => RegistrationStatus::Submitted,
'actor_id' => $student->user_id,
]);
return $submission;
});
}
}

View File

@ -11,7 +11,7 @@ class AcademicTermService
{
public function getAllForSelect(): Collection
{
return AcademicTerm::select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])->latest()->get();
return AcademicTerm::select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])->latest()->get();
}
/**
@ -27,26 +27,30 @@ public function getActive(): ?AcademicTerm
public function paginated(int $perPage = 25, string $search = '', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator
{
return AcademicTerm::query()
->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))
->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))
->latest()
->paginate($perPage);
}
public function create(array $data): AcademicTerm
{
return AcademicTerm::create($data);
return AcademicTerm::create([
...$data,
'open_semesters' => $this->normalizeOpenSemesters($data['open_semesters'] ?? []),
]);
}
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
{
$academicTerm->name = $data['name'];
$academicTerm->academic_year = $data['academic_year'];
$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;
@ -67,4 +71,13 @@ 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,7 +10,14 @@ class CourseService
{
public function getAllForSelect(): Collection
{
return Course::select(['id', 'code', 'name', 'department_id'])->get();
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();
}
public function getSemesterNumbers(): array
@ -29,14 +36,17 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
$user = auth()->user();
return Course::query()
->select(['id', 'code', 'name', 'credits', 'department_id', 'semester_number'])
->select(['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number'])
->join('departments', 'departments.id', '=', 'courses.department_id')
->with('department:id,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()
->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')
->paginate($perPage);
}

View File

@ -3,6 +3,8 @@
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;
@ -17,6 +19,7 @@ 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);
@ -24,7 +27,15 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
public function create(array $data): Department
{
return Department::create($data);
$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;
}
public function update(Department $department, array $data): Department
@ -34,9 +45,68 @@ 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', 'create-course-registrations', 'approve-course-registrations', 'reject-course-registrations',
'view-course-registrations', 'approve-course-registrations', 'reject-course-registrations',
'view-announcements', 'create-announcements', 'update-announcements', 'delete-announcements',
];

View File

@ -11,11 +11,12 @@ public function up(): void
{
Schema::create('academic_terms', function (Blueprint $table) {
$table->id();
$table->string('name', 20);
$table->string('academic_year', 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,6 +13,7 @@ public function up(): void
$table->id();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
$table->integer('semester_number')->nullable();
$table->enum('status', RegistrationStatus::values())->default(RegistrationStatus::Submitted->value);
$table->timestamp('signed_at');
$table->text('rejection_reason')->nullable();
@ -20,7 +21,7 @@ public function up(): void
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->unique(['student_id', 'academic_term_id'], 'course_reg_submissions_student_term_unique');
$table->unique(['student_id', 'academic_term_id', 'semester_number'], 'course_reg_submissions_student_term_semester_unique');
});
}

View File

@ -12,7 +12,7 @@ public function run(): void
{
AcademicTerm::insert([
[
'name' => '2026/2027',
'academic_year' => '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(),
],
[
'name' => '2026/2027',
'academic_year' => '2026/2027',
'semester' => Semester::even->value,
'start_date' => '2027-02-01',
'end_date' => '2027-07-15',

View File

@ -143,6 +143,8 @@ 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,27 +11,35 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->call([
$seeders = [
RolePermissionSeeder::class,
DepartmentSeeder::class,
AcademicTermSeeder::class,
UserSeeder::class,
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,
]);
];
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);
}
}

View File

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

View File

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

View File

@ -54,16 +54,11 @@ import { index as letterRequestsRoute } from '@/routes/admin/services/letter-req
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
import { index as studentsRoute } from '@/routes/admin/users/students';
import { index as studentCourseRegistrationsRoute } from '@/routes/student/course-registrations';
import type { Auth } from '@/types/auth';
const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
function buildNavMain({
isMahasiswa,
permissions,
}: {
isMahasiswa: boolean;
permissions: string[];
}): (NavGroup | NavItem)[] {
const can = (permission: string) => permissions.includes(permission);
@ -117,17 +112,6 @@ 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')
? [
{
@ -137,6 +121,15 @@ function buildNavMain({
},
]
: []),
...(can('view-course-registrations')
? [
{
name: 'Registrasi KRS',
url: courseRegistrationsRoute.url(),
icon: FileCheck2,
},
]
: []),
...(can('view-announcements')
? [
{
@ -289,14 +282,8 @@ function buildNavSecondary(
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { name, auth, pendingFeedbackCount } = usePage<{ auth: Auth }>()
.props;
const roleNames = auth?.user?.roles?.map((role) => role.name) ?? [];
const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role));
const isMahasiswa = !isStaff && roleNames.includes('mahasiswa');
const permissions = auth?.permissions ?? [];
const navMain = buildNavMain({
isMahasiswa,
permissions,
});
const navMain = buildNavMain({ permissions });
return (
<Sidebar collapsible="icon" {...props}>

View File

@ -65,6 +65,7 @@ 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;
@ -156,6 +157,7 @@ export function DataTable<TData, TValue>({
toolbar,
renderSubRow,
defaultExpanded = false,
groupBy,
pagination,
onPageChange,
onPerPageChange,
@ -404,58 +406,96 @@ export function DataTable<TData, TValue>({
</SortableContext>
</DndContext>
) : (
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;
(() => {
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
}
)?.className
className="py-2 text-sm font-semibold"
>
{group}
</TableCell>
</TableRow>
)}
<TableRow
data-state={
row.getIsSelected() &&
'selected'
}
>
{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,
{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>
),
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
))
</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,13 +19,17 @@ 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,
maxDate = defaultMaxDate,
}: DatePickerProps) {
return (
<Popover>
@ -50,6 +54,7 @@ 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 py-4">
<div className="grid flex-1 gap-4 overflow-x-hidden overflow-y-auto px-1 py-4">
{typeof children === 'function'
? children({ errors, processing })
: children}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ 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';
@ -44,15 +43,6 @@ 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,47 +1,33 @@
import { Head, Link, router } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { ArrowLeft, Trash2, UserPlus } from 'lucide-react';
import { ArrowLeft, Trash2 } 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,
store,
} from '@/routes/admin/manage/course-classes/enrollments';
import { destroy } from '@/routes/admin/manage/course-classes/enrollments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type {
ClassEnrollment,
EnrollmentStudent,
} from '@/types/class-enrollment';
import type { ClassEnrollment } 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() {
@ -158,25 +144,10 @@ export default function ClassEnrollmentIndex({
</CardContent>
</Card>
<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>
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
<DataTable columns={columns} data={enrollments} />
<EnrollForm
open={createOpen}
onOpenChange={setCreateOpen}
courseClassId={courseClass.id}
availableStudents={availableStudents}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
@ -194,123 +165,3 @@ 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 { Plus } from 'lucide-react';
import { Copy, Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
@ -12,11 +12,18 @@ 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 {
@ -31,6 +38,7 @@ import { useServerTable } from '@/hooks/use-server-table';
import {
index as courseClassIndex,
destroy,
duplicate,
store,
update,
} from '@/routes/admin/manage/course-classes';
@ -39,14 +47,62 @@ 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 };
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 Lecturer = {
id: number;
lecturer_number: string;
departments: { id: number; name: string }[];
user: { profile: { full_name: string } | null } | null;
};
type AcademicTerm = { id: number; name: string; semester: string };
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 Props = {
courseClasses: {
@ -59,6 +115,7 @@ type Props = {
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
highlight?: number;
filters: {
academic_term_id?: string;
@ -71,10 +128,12 @@ 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();
@ -170,15 +229,24 @@ export default function CourseClassIndex({
}
actions={
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDuplicateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
<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>
)
}
/>
@ -189,6 +257,13 @@ export default function CourseClassIndex({
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<DuplicateForm
open={duplicateOpen}
onOpenChange={setDuplicateOpen}
academicTerms={academicTerms}
/>
<EditForm
@ -203,6 +278,7 @@ export default function CourseClassIndex({
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<DataTable
@ -214,6 +290,9 @@ 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}
@ -247,28 +326,46 @@ function CreateForm({
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [courseId, setCourseId] = useState('');
const [academicTermId, setAcademicTermId] = useState('');
const [selectedCourses, setSelectedCourses] = useState<Course[]>([]);
const [lecturer, setLecturer] = useState<Lecturer | null>(null);
const courseAnchor = useComboboxAnchor();
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 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),
)
: lecturers;
: [];
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
function reset() {
setCourseId('');
setAcademicTermId('');
setSelectedCourses([]);
setLecturer(null);
}
@ -291,8 +388,18 @@ function CreateForm({
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input type="hidden" name="academic_term_id" />
<Select name="academic_term_id">
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={(value) => {
setAcademicTermId(value);
setSelectedCourses([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
@ -309,39 +416,6 @@ 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{' '}
@ -353,22 +427,19 @@ function CreateForm({
value={lecturer?.id ?? ''}
/>
<Combobox
items={availableLecturers}
items={lecturers}
value={lecturer}
onValueChange={setLecturer}
onValueChange={(value) => {
setLecturer(value);
setSelectedCourses([]);
}}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!selectedCourse}
>
<ComboboxInput
disabled={!selectedCourse}
placeholder={
selectedCourse
? 'Pilih dosen pengampu'
: 'Pilih mata kuliah terlebih dahulu'
}
placeholder="Pilih dosen pengampu"
className="w-full"
/>
<ComboboxContent>
@ -391,6 +462,78 @@ 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" />
@ -421,6 +564,7 @@ function EditForm({
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -428,9 +572,15 @@ function EditForm({
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [courseId, setCourseId] = useState(
editing ? String(editing.course_id) : '',
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 [lecturer, setLecturer] = useState<Lecturer | null>(
editing
@ -438,15 +588,30 @@ function EditForm({
: null,
);
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 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),
)
: lecturers;
: [];
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
return (
<FormDialog
@ -462,36 +627,36 @@ function EditForm({
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Mata Kuliah{' '}
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="course_id"
value={courseId}
name="academic_term_id"
value={academicTermId}
/>
<Select
value={courseId}
value={academicTermId}
onValueChange={(value) => {
setCourseId(value);
setLecturer(null);
setAcademicTermId(value);
setCourse(null);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih mata kuliah" />
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{courses.map((course) => (
{academicTerms.map((term) => (
<SelectItem
key={course.id}
value={String(course.id)}
key={term.id}
value={String(term.id)}
>
{course.code} - {course.name}
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.course_id} />
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2">
<Label>
@ -504,22 +669,19 @@ function EditForm({
value={lecturer?.id ?? ''}
/>
<Combobox
items={availableLecturers}
items={lecturers}
value={lecturer}
onValueChange={setLecturer}
onValueChange={(value) => {
setLecturer(value);
setCourse(null);
}}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!selectedCourse}
>
<ComboboxInput
disabled={!selectedCourse}
placeholder={
selectedCourse
? 'Pilih dosen pengampu'
: 'Pilih mata kuliah terlebih dahulu'
}
placeholder="Pilih dosen pengampu"
className="w-full"
/>
<ComboboxContent>
@ -544,28 +706,63 @@ function EditForm({
</div>
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
<Select
name="academic_term_id"
defaultValue={String(editing.academic_term_id)}
<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}
>
<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} />
<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} />
</div>
<div className="grid gap-2">
<Label>Metode</Label>
@ -592,3 +789,133 @@ 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,25 +1,12 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Check, Eye, X } from 'lucide-react';
import { Eye } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { show } from '@/routes/admin/manage/course-registrations';
import type { CourseRegistrationSubmission } from '@/types/course-registration';
import { RegistrationStatusLabels } from '@/types/course-registration';
import type { CourseRegistrationRow } from '@/types/course-registration';
export type { CourseRegistrationSubmission } from '@/types/course-registration';
type CreateColumnsParams = {
handleApprove: (submission: CourseRegistrationSubmission) => void;
handleRejectClick: (submission: CourseRegistrationSubmission) => void;
canApprove: boolean;
canReject: boolean;
};
export function createCourseRegistrationColumns(
params: CreateColumnsParams,
): ColumnDef<CourseRegistrationSubmission>[] {
const { handleApprove, handleRejectClick, canApprove, canReject } = params;
export type { CourseRegistrationRow } from '@/types/course-registration';
export function createCourseRegistrationColumns(): ColumnDef<CourseRegistrationRow>[] {
return [
{
id: 'student',
@ -33,98 +20,35 @@ export function createCourseRegistrationColumns(
{student?.user?.profile?.full_name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{student?.student_number} &middot;{' '}
{student?.department?.name ?? '-'}
{student?.student_number}
</p>
</div>
);
},
},
{
id: 'semester',
header: () => <span className="block text-center">Semester</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => row.original.student?.current_semester ?? '-',
},
{
id: 'count',
header: () => <span className="block text-center">Jumlah</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => row.original.course_registrations.length,
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'w-[140px] text-center',
headerClassName: 'w-[140px] text-center',
},
cell: ({ row }) => {
const status = row.original.status;
return (
<div className="flex justify-center">
<Badge
variant={
status === 'approved'
? 'default'
: status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[status]}
</Badge>
</div>
);
},
id: 'department',
header: () => <span>Jurusan</span>,
cell: ({ row }) => row.original.student?.department?.name ?? '-',
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[130px] text-center',
headerClassName: 'w-[130px] text-center',
},
cell: ({ row }) => {
const submission = row.original;
return (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(submission.id),
},
{
label: 'Setujui',
icon: <Check className="h-4 w-4" />,
iconClassName: 'text-primary',
show:
submission.status === 'submitted' &&
canApprove,
onClick: () => handleApprove(submission),
},
{
label: 'Tolak',
icon: <X className="h-4 w-4" />,
iconClassName: 'text-destructive',
show:
submission.status === 'submitted' &&
canReject,
onClick: () => handleRejectClick(submission),
},
]}
/>
);
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(row.original.student_id),
},
]}
/>
),
},
];
}

View File

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

View File

@ -1,150 +1,162 @@
import { Head, Link } from '@inertiajs/react';
import { Form, Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Check, Inbox, Send, X } from 'lucide-react';
import { useState } from 'react';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { SignaturePad } from '@/components/signature-pad';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { index as courseRegistrationIndex } from '@/routes/admin/manage/course-registrations';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { CourseRegistrationSubmission } from '@/types/course-registration';
import { RegistrationStatusLabels } from '@/types/course-registration';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import {
advisor_signature,
approve,
kaprodi_signature,
reject,
save,
} from '@/routes/admin/manage/course-registrations';
import type {
CourseRegistrationSubmission,
CourseRegistrationSubmissionEntry,
CourseRegistrationSubmissionLog,
} from '@/types/course-registration';
import {
RegistrationStatusLabels,
semesterKey,
} from '@/types/course-registration';
type Props = {
submission: CourseRegistrationSubmission;
const RegistrationStatusVariants: Record<
CourseRegistrationSubmission['status'],
'default' | 'secondary' | 'destructive' | 'outline'
> = {
submitted: 'outline',
approved: 'default',
rejected: 'destructive',
};
export default function CourseRegistrationShow({ submission }: Props) {
const student = submission.student;
function isSemesterOpenForStudent(
semester: number | null,
openSemesters: number[],
studentCurrentSemester: number,
): boolean {
return (
semester !== null &&
semester === studentCurrentSemester &&
openSemesters.includes(semester)
);
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[auto_1fr] gap-x-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">: {value}</span>
</div>
);
}
function SignatureBox({
signatureUrl,
alt,
error,
editable,
}: {
signatureUrl: string | null;
alt: string;
error?: string;
editable: boolean;
}) {
if (!editable) {
if (!signatureUrl) {
return <div className="h-28 w-48" />;
}
return (
<div className="h-28 w-48 overflow-hidden rounded border bg-white">
<img
src={signatureUrl}
alt={alt}
className="h-full w-full object-contain"
/>
</div>
);
}
return <SignaturePad name="signature" error={error} compact />;
}
function ReviewerSignatureField({
signatureUrl,
alt,
canSign,
submitUrl,
}: {
signatureUrl: string | null;
alt: string;
canSign: boolean;
submitUrl: string;
}) {
const [file, setFile] = useState<File | null>(null);
const [processing, setProcessing] = useState(false);
if (signatureUrl) {
return (
<div className="h-28 w-48 overflow-hidden rounded border bg-white">
<img
src={signatureUrl}
alt={alt}
className="h-full w-full object-contain"
/>
</div>
);
}
if (!canSign) {
return <div className="h-28 w-48" />;
}
function handleSave() {
if (!file) {
return;
}
setProcessing(true);
router.patch(
submitUrl,
{ signature: file },
{
preserveScroll: true,
onFinish: () => setProcessing(false),
},
);
}
return (
<>
<Head title="Detail Registrasi KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Registrasi KRS"
actions={
<Button variant="outline" asChild>
<Link href={courseRegistrationIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>
{student?.user?.profile?.full_name ?? 'N/A'}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{student?.student_number} &middot;{' '}
{student?.department?.name ?? '-'} &middot;
Semester {student?.current_semester ?? '-'}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Periode:{' '}
{submission.academic_term
? formatAcademicTermLabel(
submission.academic_term,
)
: '-'}
</p>
</div>
<Badge
variant={
submission.status === 'approved'
? 'default'
: submission.status === 'rejected'
? 'destructive'
: 'outline'
}
>
{RegistrationStatusLabels[submission.status]}
</Badge>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Tanda Tangan
</CardTitle>
</CardHeader>
<CardContent>
{submission.signature_url ? (
<div className="w-fit rounded-md border bg-white p-2">
<img
src={submission.signature_url}
alt="Tanda tangan"
className="h-24"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
Tidak ada tanda tangan.
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Ditandatangani pada{' '}
{format(
new Date(submission.signed_at),
'd MMM yyyy, HH:mm',
)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Mata Kuliah (
{submission.course_registrations.length})
</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.course_registrations.map((registration) => (
<div
key={registration.id}
className="rounded-md border p-3"
>
<p className="font-medium">
{registration.course_class?.course?.name ??
'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{registration.course_class?.course?.code ??
'-'}
</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Riwayat</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.logs.map((log, index) => (
<LogBubble
key={log.id}
log={log}
isResubmission={
log.status === 'submitted' &&
submission.logs
.slice(0, index)
.some((l) => l.status === 'submitted')
}
/>
))}
</CardContent>
</Card>
</div>
</>
<div className="grid gap-1">
<SignaturePad name="_signature" compact onCapture={setFile} />
<Button
type="button"
size="sm"
disabled={!file || processing}
onClick={handleSave}
>
{processing ? 'Menyimpan...' : 'Simpan TTD'}
</Button>
</div>
);
}
@ -152,7 +164,7 @@ function LogBubble({
log,
isResubmission,
}: {
log: CourseRegistrationSubmission['logs'][number];
log: CourseRegistrationSubmissionLog;
isResubmission: boolean;
}) {
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
@ -182,3 +194,563 @@ 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,6 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { ActiveStatusSwitch } from '@/components/active-status-switch';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { SemesterLabels } from '@/types/academic-term';
import type { AcademicTerm } from '@/types/academic-term';
import type { Semester } from '@/types/academic-term';
@ -31,11 +32,11 @@ export function createAcademicTermColumns(
const columns: ColumnDef<AcademicTerm>[] = [
{
accessorKey: 'name',
header: () => <span>Nama</span>,
accessorKey: 'academic_year',
header: () => <span>Tahun Ajaran</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('name') as string}
{row.getValue('academic_year') as string}
</span>
),
},
@ -66,6 +67,33 @@ export function createAcademicTermColumns(
</span>
),
},
{
id: 'open_semesters',
header: () => <span>Semester Dibuka</span>,
cell: ({ row }) => {
const openSemesters = row.original.open_semesters ?? [];
if (openSemesters.length === 0) {
return (
<span className="text-xs text-muted-foreground">
Semua Tertutup
</span>
);
}
return (
<div className="flex flex-wrap gap-1">
{[...openSemesters]
.sort((a, b) => a - b)
.map((semester) => (
<Badge key={semester} variant="outline">
{semester}
</Badge>
))}
</div>
);
},
},
{
accessorKey: 'is_active',
header: () => <span className="block text-center">Status</span>,

View File

@ -182,7 +182,7 @@ export default function AcademicTermIndex({
<DataTable
columns={columns}
data={academicTerms.data}
searchKey="name"
searchKey="academic_year"
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.name}"? Tindakan ini tidak dapat dibatalkan.`
`Apakah Anda yakin ingin menghapus periode akademik "${academicTerm.academic_year}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
@ -241,15 +241,16 @@ function CreateForm({
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama <span className="text-destructive">*</span>
<Label htmlFor="academic_year">
Tahun Ajaran{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama periode akademik"
id="academic_year"
name="academic_year"
placeholder="Masukkan tahun ajaran, contoh: 2025/2026"
/>
<InputError message={errors.name} />
<InputError message={errors.academic_year} />
</div>
<div className="grid gap-2">
<Label>
@ -329,6 +330,7 @@ function CreateForm({
<Checkbox id="is_active" name="is_active" value="1" />
<Label htmlFor="is_active">Aktif</Label>
</div>
<OpenSemestersField idPrefix="create" />
</div>
)}
</FormDialog>
@ -368,16 +370,17 @@ function EditForm({
editing && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama <span className="text-destructive">*</span>
<Label htmlFor="edit-academic_year">
Tahun Ajaran{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama periode akademik"
defaultValue={editing.name}
id="edit-academic_year"
name="academic_year"
placeholder="Masukkan tahun ajaran, contoh: 2025/2026"
defaultValue={editing.academic_year}
/>
<InputError message={errors.name} />
<InputError message={errors.academic_year} />
</div>
<div className="grid gap-2">
<Label>
@ -467,9 +470,49 @@ 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,7 +1,6 @@
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';
@ -33,11 +32,6 @@ 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>,
@ -51,28 +45,6 @@ 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,6 +191,9 @@ 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,6 +55,21 @@ 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,13 +1,23 @@
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 {
@ -26,7 +36,7 @@ import {
update,
} from '@/routes/admin/master/departments';
import { DegreeLevels } from '@/types/department';
import type { Department } from '@/types/department';
import type { Department, DepartmentLecturer } from '@/types/department';
import { createDepartmentColumns } from './columns';
type Props = {
@ -37,10 +47,19 @@ type Props = {
per_page: number;
total: number;
};
lecturers: DepartmentLecturer[];
highlight?: number;
};
export default function DepartmentIndex({ departments, highlight }: Props) {
function lecturerLabel(lecturer: DepartmentLecturer): string {
return `${lecturer.user?.profile?.full_name ?? 'N/A'} - ${lecturer.lecturer_number}`;
}
export default function DepartmentIndex({
departments,
lecturers,
highlight,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [deleting, setDeleting] = useState<Department | null>(null);
@ -127,7 +146,11 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
}
/>
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
lecturers={lecturers}
/>
<EditForm
key={editing?.id}
@ -138,6 +161,7 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
}
}}
editing={editing}
lecturers={lecturers}
/>
<DataTable
@ -169,12 +193,113 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
);
}
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
@ -222,6 +347,7 @@ function CreateForm({
</Select>
<InputError message={errors.degree_level} />
</div>
<KaprodiFields errors={errors} lecturers={lecturers} />
</div>
)}
</FormDialog>
@ -232,10 +358,12 @@ function EditForm({
open,
onOpenChange,
editing,
lecturers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Department | null;
lecturers: DepartmentLecturer[];
}) {
return (
<FormDialog
@ -297,6 +425,11 @@ 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>Dosen Wali</span>,
header: () => <span>Pembimbing Akademik</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">
Dosen Wali
Pembimbing Akademik
</Label>
<p>
{log.lecturer?.user?.profile?.full_name ??
@ -338,7 +338,8 @@ function AdvisingLogFields({
<>
<div className="grid gap-2">
<Label>
Dosen Wali <span className="text-destructive">*</span>
Pembimbing Akademik{' '}
<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>Dosen Wali</Label>
<Label>Pembimbing Akademik</Label>
<Input
disabled
value={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,22 +10,23 @@ export const SemesterLabels: Record<Semester, string> = {
export type AcademicTerm = {
id: number;
name: string;
academic_year: 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: {
name: string;
academic_year: string;
semester: string;
}): string {
const label = SemesterLabels[term.semester as Semester];
return label ? `${term.name} (${label})` : term.name;
return label ? `${term.academic_year} (${label})` : term.academic_year;
}

View File

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

View File

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

View File

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

View File

@ -98,15 +98,14 @@
});
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
Route::resource('course-registrations', CourseRegistrationController::class)
->only(['index', 'store'])
->middlewareFor(['index'], 'permission:view-course-registrations')
->middlewareFor(['store'], 'permission:create-course-registrations');
Route::prefix('course-registrations/{submission}')->name('course-registrations.')->group(function () {
Route::get('/', [CourseRegistrationController::class, 'show'])->name('show')->middleware('permission:view-course-registrations');
Route::patch('approve', [CourseRegistrationController::class, 'approve'])->name('approve')->middleware('permission:approve-course-registrations');
Route::patch('reject', [CourseRegistrationController::class, 'reject'])->name('reject')->middleware('permission:reject-course-registrations');
Route::prefix('course-registrations')->name('course-registrations.')->group(function () {
Route::get('/', [CourseRegistrationController::class, 'index'])->name('index')->middleware('permission:view-course-registrations');
Route::patch('submissions/{submission}/approve', [CourseRegistrationController::class, 'approve'])->name('approve')->middleware('permission:approve-course-registrations');
Route::patch('submissions/{submission}/reject', [CourseRegistrationController::class, 'reject'])->name('reject')->middleware('permission:reject-course-registrations');
Route::get('{student}', [CourseRegistrationController::class, 'show'])->name('show')->middleware('permission:view-course-registrations');
Route::put('{student}/{semester}', [CourseRegistrationController::class, 'save'])->name('save')->middleware('permission:view-course-registrations');
Route::patch('{student}/{semester}/kaprodi-signature', [CourseRegistrationController::class, 'signAsKaprodi'])->name('kaprodi_signature')->middleware('permission:view-course-registrations');
Route::patch('{student}/{semester}/advisor-signature', [CourseRegistrationController::class, 'signAsAdvisor'])->name('advisor_signature')->middleware('permission:approve-course-registrations');
});
Route::resource('course-classes', CourseClassController::class)
@ -116,9 +115,12 @@
->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');
});
@ -130,15 +132,6 @@
});
});
Route::middleware('role:mahasiswa')->group(function () {
Route::prefix('course-registrations')->name('student.course-registrations.')->group(function () {
Route::get('/', [CourseRegistrationController::class, 'mine'])->name('index');
Route::get('create', [CourseRegistrationController::class, 'create'])->name('create');
Route::post('/', [CourseRegistrationController::class, 'store'])->name('store');
Route::get('{submission}', [CourseRegistrationController::class, 'show'])->name('show');
});
});
Route::prefix('admin/finances')->name('admin.finances.')->group(function () {
Route::resource('tuition-invoices', TuitionInvoiceController::class)
->except(['create', 'edit', 'show'])