Compare commits
24 Commits
93ddee8fd6
...
8528a3258d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8528a3258d | ||
|
|
a94ebfb77d | ||
|
|
378ddebac9 | ||
|
|
cdd81caf22 | ||
|
|
1b68cf6125 | ||
|
|
c57787eb20 | ||
|
|
5613078c00 | ||
|
|
c1cee7cd45 | ||
|
|
0d27a9e706 | ||
|
|
65618bf961 | ||
|
|
03c3fc61af | ||
|
|
584056e379 | ||
|
|
28a25fabfd | ||
|
|
9892f60e5e | ||
|
|
86863ff2f8 | ||
|
|
9823b879d8 | ||
|
|
ded7217dbc | ||
|
|
6c55fb5f36 | ||
|
|
0327b07669 | ||
|
|
692b84cc1b | ||
|
|
6621757713 | ||
|
|
a7aec7245b | ||
|
|
2bb892ef50 | ||
|
|
10c3e42228 |
@ -3,7 +3,6 @@
|
|||||||
namespace App\Http\Controllers\Admin\Manage;
|
namespace App\Http\Controllers\Admin\Manage;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\ClassEnrollmentRequest;
|
|
||||||
use App\Models\ClassEnrollment;
|
use App\Models\ClassEnrollment;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Services\Admin\Manage\ClassEnrollmentService;
|
use App\Services\Admin\Manage\ClassEnrollmentService;
|
||||||
@ -24,17 +23,9 @@ public function index(CourseClass $courseClass): Response
|
|||||||
return Inertia::render('admin/manage/course-classes/enrollments', [
|
return Inertia::render('admin/manage/course-classes/enrollments', [
|
||||||
'courseClass' => $courseClass,
|
'courseClass' => $courseClass,
|
||||||
'enrollments' => $this->service->forClass($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
|
public function destroy(CourseClass $courseClass, ClassEnrollment $enrollment): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->service->unenroll($enrollment);
|
$this->service->unenroll($enrollment);
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\CourseClassRequest;
|
use App\Http\Requests\Admin\Manage\CourseClassRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\DuplicateCourseClassesRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
use App\Services\Admin\Manage\CourseClassService;
|
||||||
@ -34,15 +35,21 @@ public function index(PaginatedRequest $request): Response
|
|||||||
'courses' => $this->courseService->getAllForSelect(),
|
'courses' => $this->courseService->getAllForSelect(),
|
||||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||||
|
'courseClassAssignments' => $this->service->getAllForSelect(),
|
||||||
'filters' => $request->only(['academic_term_id', 'method']),
|
'filters' => $request->only(['academic_term_id', 'method']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(CourseClassRequest $request): RedirectResponse
|
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');
|
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();
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,17 +3,16 @@
|
|||||||
namespace App\Http\Controllers\Admin\Manage;
|
namespace App\Http\Controllers\Admin\Manage;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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\RejectCourseRegistrationRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\SaveCourseRegistrationRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\SignCourseRegistrationRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
|
use App\Models\Course;
|
||||||
use App\Models\CourseRegistrationSubmission;
|
use App\Models\CourseRegistrationSubmission;
|
||||||
use App\Models\Student;
|
use App\Models\Student;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
|
||||||
use App\Services\Admin\Manage\CourseRegistrationService;
|
use App\Services\Admin\Manage\CourseRegistrationService;
|
||||||
use App\Services\Admin\Master\AcademicTermService;
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use App\Services\Admin\Users\StudentService;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@ -21,109 +20,154 @@ class CourseRegistrationController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CourseRegistrationService $service,
|
private readonly CourseRegistrationService $service,
|
||||||
private readonly StudentService $studentService,
|
|
||||||
private readonly AcademicTermService $academicTermService,
|
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', [
|
return Inertia::render('admin/manage/course-registrations/index', [
|
||||||
|
'departments' => $this->service->departmentSummary(),
|
||||||
'registrations' => $this->service->paginated(
|
'registrations' => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
status: $request->validated('status'),
|
departmentId: $request->validated('department_id'),
|
||||||
academicTermId: $request->validated('academic_term_id'),
|
advisorLecturerId: ! $isKaprodi && $user->hasRole('dosen') ? $user->lecturer?->id : null,
|
||||||
|
ledDepartmentIds: $isKaprodi ? $user->ledDepartmentIds() : null,
|
||||||
),
|
),
|
||||||
'students' => $this->studentService->getAllForSelect(),
|
'filters' => $request->only(['department_id']),
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
|
||||||
'courseClasses' => $this->courseClassService->getAllForSelect(),
|
|
||||||
'filters' => $request->only(['status', 'academic_term_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();
|
abort_if($isStudent && $user->student->id !== $student->id, 403);
|
||||||
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
|
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
|
||||||
$activeSubmission = $term ? $this->service->currentSubmission($student, $term) : null;
|
|
||||||
|
|
||||||
return Inertia::render('student/course-registrations/index', [
|
$backHref = $isStudent
|
||||||
'academicTerm' => $term,
|
? null
|
||||||
'payment' => $payment,
|
: route('admin.manage.course-registrations.index');
|
||||||
'canSubmit' => $this->service->canSubmit($activeSubmission),
|
|
||||||
'activeSubmission' => $activeSubmission,
|
|
||||||
'submissions' => $this->service->submissionsFor($student),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(Request $request): Response|RedirectResponse
|
$canReview = ! $isStudent
|
||||||
{
|
&& $user->can('approve-course-registrations')
|
||||||
$student = $this->currentStudent($request);
|
&& $user->canReviewCourseRegistrationOf($student);
|
||||||
|
|
||||||
$student->load(['user.profile', 'department', 'academicAdvisor.user.profile']);
|
$canSignAsKaprodi = $user->isKaprodiOf($student);
|
||||||
|
$canSignAsAdvisor = $user->isAdvisorOf($student);
|
||||||
|
|
||||||
$term = $this->academicTermService->getActive();
|
$activeTerm = $this->academicTermService->getActive();
|
||||||
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
|
$allCourseClasses = $activeTerm
|
||||||
$submission = $term ? $this->service->currentSubmission($student, $term) : null;
|
? $this->service->availableCourseClasses($student->department_id, $activeTerm->id)
|
||||||
|
: collect();
|
||||||
|
|
||||||
if (! $term || ! $payment['is_paid'] || ! $this->service->canSubmit($submission)) {
|
$departmentCourses = $this->service->departmentCourses($student->department_id);
|
||||||
return to_route('student.course-registrations.index');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Inertia::render('student/course-registrations/create', [
|
$semesters = $departmentCourses
|
||||||
'student' => $student,
|
->map(fn (Course $course) => $course->semester_number)
|
||||||
'academicTerm' => $term,
|
->unique()
|
||||||
'submission' => $submission,
|
->sort()
|
||||||
'availableCourses' => $this->service->availableCourseClasses($student, $term),
|
->values();
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, CourseRegistrationSubmission $submission): Response
|
$submissions = $activeTerm
|
||||||
{
|
? $semesters->mapWithKeys(fn (?int $semesterNumber) => [
|
||||||
if ($request->routeIs('student.*')) {
|
CourseRegistrationSubmission::semesterKey($semesterNumber) => $this->service->buildSubmissionPayload(
|
||||||
$student = $this->currentStudent($request);
|
$student,
|
||||||
|
$activeTerm,
|
||||||
abort_if($submission->student_id !== $student->id, 403);
|
$semesterNumber,
|
||||||
|
$departmentCourses,
|
||||||
return Inertia::render('student/course-registrations/show', [
|
$allCourseClasses,
|
||||||
'submission' => $this->service->withDetails($submission),
|
),
|
||||||
]);
|
])
|
||||||
}
|
: collect();
|
||||||
|
|
||||||
return Inertia::render('admin/manage/course-registrations/show', [
|
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.*')) {
|
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
|
||||||
$student = $this->currentStudent($request);
|
|
||||||
$term = $this->academicTermService->getActive();
|
|
||||||
|
|
||||||
$submission = $this->service->sign(
|
$activeTerm = $this->academicTermService->getActive();
|
||||||
$student,
|
|
||||||
$term,
|
abort_if($activeTerm === null, 422);
|
||||||
$request->validated('course_class_ids'),
|
|
||||||
|
$selectableIds = $this->service->selectableCourseClassIds(
|
||||||
|
$student->department_id,
|
||||||
|
$activeTerm->id,
|
||||||
|
$semesterNumber,
|
||||||
|
$activeTerm->open_semesters ?? [],
|
||||||
|
$student->current_semester,
|
||||||
|
);
|
||||||
|
|
||||||
|
$selectedIds = array_values(array_intersect(
|
||||||
|
$request->validated('course_class_ids') ?? [],
|
||||||
|
$selectableIds,
|
||||||
|
));
|
||||||
|
|
||||||
|
$submission = $this->service->findOrCreateForStudent($student, $activeTerm, $semesterNumber);
|
||||||
|
|
||||||
|
$this->service->saveRegistrations(
|
||||||
|
$submission,
|
||||||
|
$selectedIds,
|
||||||
|
$activeTerm->id,
|
||||||
|
$student->id,
|
||||||
$request->file('signature'),
|
$request->file('signature'),
|
||||||
);
|
);
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditandatangani.']);
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disimpan.']);
|
||||||
|
|
||||||
return to_route('student.course-registrations.show', $submission);
|
return to_route('admin.manage.course-registrations.show', $student->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->service->create($request->validated());
|
public function signAsKaprodi(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->isKaprodiOf($student), 403);
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']);
|
$submission = $this->service->findOrCreateForSemester($student, $semester);
|
||||||
|
|
||||||
return to_route('admin.manage.course-registrations.index');
|
$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
|
public function approve(CourseRegistrationSubmission $submission): RedirectResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
|
||||||
|
|
||||||
$this->service->approve($submission);
|
$this->service->approve($submission);
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disetujui.'])->back();
|
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
|
public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
|
||||||
|
|
||||||
$this->service->reject($submission, $request->validated('reason'));
|
$this->service->reject($submission, $request->validated('reason'));
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function currentStudent(Request $request): Student
|
|
||||||
{
|
|
||||||
$student = $request->user()->student;
|
|
||||||
|
|
||||||
abort_if(! $student, 403);
|
|
||||||
|
|
||||||
return $student;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
use App\Services\Admin\Master\DepartmentService;
|
use App\Services\Admin\Master\DepartmentService;
|
||||||
|
use App\Services\Admin\Users\LecturerService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -15,12 +16,14 @@ class DepartmentController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DepartmentService $service,
|
private readonly DepartmentService $service,
|
||||||
|
private readonly LecturerService $lecturerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/master/departments/index', [
|
return Inertia::render('admin/master/departments/index', [
|
||||||
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
|
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
|
||||||
|
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,7 +3,10 @@
|
|||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
use App\Enums\ClassMethod;
|
use App\Enums\ClassMethod;
|
||||||
|
use App\Enums\Semester;
|
||||||
|
use App\Models\AcademicTerm;
|
||||||
use App\Models\Course;
|
use App\Models\Course;
|
||||||
|
use App\Models\Lecturer;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -16,10 +19,73 @@ public function authorize(): bool
|
|||||||
|
|
||||||
public function rules(): array
|
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;
|
$departmentId = Course::find($this->input('course_id'))?->department_id;
|
||||||
|
|
||||||
return [
|
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' => [
|
'lecturer_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
|
||||||
|
|
||||||
use App\Services\Admin\Manage\CourseRegistrationService;
|
|
||||||
use App\Services\Admin\Master\AcademicTermService;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class CourseRegistrationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
if (! $this->routeIs('student.*')) {
|
|
||||||
return $this->user()->can('create-course-registrations');
|
|
||||||
}
|
|
||||||
|
|
||||||
$student = $this->user()->student;
|
|
||||||
|
|
||||||
if (! $student) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$term = app(AcademicTermService::class)->getActive();
|
|
||||||
|
|
||||||
if (! $term) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(CourseRegistrationService::class);
|
|
||||||
$submission = $service->currentSubmission($student, $term);
|
|
||||||
|
|
||||||
if (! $service->canSubmit($submission)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $service->paymentStatus($student, $term)['is_paid'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
if ($this->routeIs('student.*')) {
|
|
||||||
$student = $this->user()->student;
|
|
||||||
$term = app(AcademicTermService::class)->getActive();
|
|
||||||
$allowedIds = $student && $term
|
|
||||||
? app(CourseRegistrationService::class)->allowedCourseClassIds($student, $term)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'course_class_ids' => ['required', 'array', 'min:1'],
|
|
||||||
'course_class_ids.*' => ['integer', Rule::in($allowedIds)],
|
|
||||||
'signature' => ['required', 'image', 'max:2048'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
|
||||||
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
|
||||||
'course_class_ids' => ['required', 'array', 'min:1'],
|
|
||||||
'course_class_ids.*' => [
|
|
||||||
'integer',
|
|
||||||
Rule::exists('course_classes', 'id'),
|
|
||||||
Rule::unique('course_registrations', 'course_class_id')
|
|
||||||
->where('student_id', $this->input('student_id')),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function attributes(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'course_class_ids' => 'mata kuliah',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,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).');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SaveCourseRegistrationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$student = $this->route('student');
|
||||||
|
|
||||||
|
return $this->user()->hasRole('mahasiswa')
|
||||||
|
&& $this->user()->student?->id === $student?->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'course_class_ids' => ['array'],
|
||||||
|
'course_class_ids.*' => ['integer'],
|
||||||
|
'signature' => ['nullable', 'file', 'image', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SignCourseRegistrationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'signature' => ['required', 'file', 'image', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,13 +16,13 @@ public function authorize(): bool
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => [
|
'academic_year' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'max:20',
|
'max:20',
|
||||||
function ($attribute, $value, $fail) {
|
function ($attribute, $value, $fail) {
|
||||||
if (! preg_match('/^(\d{4})\/(\d{4})$/', $value, $matches)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@ -40,45 +40,21 @@ function ($attribute, $value, $fail) {
|
|||||||
'string',
|
'string',
|
||||||
Rule::in(Semester::values()),
|
Rule::in(Semester::values()),
|
||||||
Rule::unique('academic_terms')->where(function ($query) {
|
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')),
|
})->ignore($this->route('academic_term')),
|
||||||
],
|
],
|
||||||
'start_date' => [
|
'start_date' => [
|
||||||
'required',
|
'required',
|
||||||
'date',
|
'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' => [
|
'end_date' => [
|
||||||
'required',
|
'required',
|
||||||
'date',
|
'date',
|
||||||
'after_or_equal:start_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'],
|
'is_active' => ['boolean'],
|
||||||
|
'open_semesters' => ['nullable', 'array'],
|
||||||
|
'open_semesters.*' => ['integer', 'between:1,8'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,14 @@ public function rules(): array
|
|||||||
],
|
],
|
||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:100'],
|
||||||
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])],
|
'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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,8 @@ public function rules(): array
|
|||||||
'academic_advisor_id' => [
|
'academic_advisor_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('lecturers', 'id'),
|
Rule::exists('lecturer_department', 'lecturer_id')
|
||||||
|
->where('department_id', $this->input('department_id')),
|
||||||
],
|
],
|
||||||
'status' => [
|
'status' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@ -28,6 +28,7 @@ protected function casts(): array
|
|||||||
'start_date' => 'date',
|
'start_date' => 'date',
|
||||||
'end_date' => 'date',
|
'end_date' => 'date',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'open_semesters' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[Appends(['signature_url'])]
|
#[Appends(['signature_url', 'kaprodi_signature_url', 'advisor_signature_url'])]
|
||||||
class CourseRegistrationSubmission extends Model implements HasMedia
|
class CourseRegistrationSubmission extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
use InteractsWithMedia;
|
use InteractsWithMedia;
|
||||||
@ -22,6 +22,7 @@ protected function casts(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'status' => RegistrationStatus::class,
|
'status' => RegistrationStatus::class,
|
||||||
|
'semester_number' => 'integer',
|
||||||
'signed_at' => 'datetime',
|
'signed_at' => 'datetime',
|
||||||
'reviewed_at' => 'datetime',
|
'reviewed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
@ -30,6 +31,8 @@ protected function casts(): array
|
|||||||
public function registerMediaCollections(): void
|
public function registerMediaCollections(): void
|
||||||
{
|
{
|
||||||
$this->addMediaCollection('signature')->singleFile();
|
$this->addMediaCollection('signature')->singleFile();
|
||||||
|
$this->addMediaCollection('kaprodi_signature')->singleFile();
|
||||||
|
$this->addMediaCollection('advisor_signature')->singleFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function student(): BelongsTo
|
public function student(): BelongsTo
|
||||||
@ -63,4 +66,28 @@ protected function signatureUrl(): Attribute
|
|||||||
get: fn () => $this->getFirstMediaUrl('signature') ?: null,
|
get: fn () => $this->getFirstMediaUrl('signature') ?: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function kaprodiSignatureUrl(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => $this->getFirstMediaUrl('kaprodi_signature') ?: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function advisorSignatureUrl(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => $this->getFirstMediaUrl('advisor_signature') ?: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function semesterKey(?int $semesterNumber): string
|
||||||
|
{
|
||||||
|
return $semesterNumber === null ? 'lainnya' : (string) $semesterNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parseSemesterKey(string $semester): ?int
|
||||||
|
{
|
||||||
|
return $semester === 'lainnya' ? null : (int) $semester;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,4 +62,54 @@ public function feedbacks(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Feedback::class);
|
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()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ public function paginated(
|
|||||||
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
|
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
|
||||||
->withSum('payments as paid_total', 'amount_paid')
|
->withSum('payments as paid_total', 'amount_paid')
|
||||||
->withCount('payments')
|
->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) {
|
->when($status, fn ($q) => match ($status) {
|
||||||
'paid' => $q->havingRaw('COALESCE(paid_total, 0) >= amount_due'),
|
'paid' => $q->havingRaw('COALESCE(paid_total, 0) >= amount_due'),
|
||||||
'partial' => $q->havingRaw('COALESCE(paid_total, 0) > 0 AND COALESCE(paid_total, 0) < amount_due'),
|
'partial' => $q->havingRaw('COALESCE(paid_total, 0) > 0 AND COALESCE(paid_total, 0) < amount_due'),
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Models\ClassEnrollment;
|
use App\Models\ClassEnrollment;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Models\Student;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class ClassEnrollmentService
|
class ClassEnrollmentService
|
||||||
@ -17,25 +16,6 @@ public function forClass(CourseClass $courseClass): Collection
|
|||||||
->get();
|
->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
|
public function unenroll(ClassEnrollment $enrollment): bool
|
||||||
{
|
{
|
||||||
return $enrollment->delete();
|
return $enrollment->delete();
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CourseClassService
|
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
|
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return CourseClass::query()
|
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')
|
->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($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($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
|
||||||
->when($method, fn ($q) => $q->where('method', $method))
|
->when($method, fn ($q) => $q->where('course_classes.method', $method))
|
||||||
->latest()
|
->orderByDesc('academic_terms.start_date')
|
||||||
|
->orderBy('courses.semester_number')
|
||||||
|
->orderBy('courses.name')
|
||||||
->paginate($perPage);
|
->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
|
public function update(CourseClass $courseClass, array $data): CourseClass
|
||||||
@ -48,4 +62,47 @@ public function delete(CourseClass $courseClass): bool
|
|||||||
{
|
{
|
||||||
return $courseClass->delete();
|
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];
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,16 @@
|
|||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage;
|
||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
|
use App\Enums\StudentStatus;
|
||||||
use App\Models\AcademicTerm;
|
use App\Models\AcademicTerm;
|
||||||
use App\Models\ClassEnrollment;
|
use App\Models\ClassEnrollment;
|
||||||
use App\Models\Course;
|
use App\Models\Course;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Models\CourseRegistration;
|
use App\Models\CourseRegistration;
|
||||||
use App\Models\CourseRegistrationSubmission;
|
use App\Models\CourseRegistrationSubmission;
|
||||||
|
use App\Models\Department;
|
||||||
use App\Models\Student;
|
use App\Models\Student;
|
||||||
use App\Models\TuitionInvoice;
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -18,70 +20,231 @@
|
|||||||
|
|
||||||
class CourseRegistrationService
|
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()
|
return Department::query()
|
||||||
->select(['id', 'student_id', 'academic_term_id', 'status', 'signed_at', 'rejection_reason', 'reviewed_by', 'reviewed_at'])
|
->select('id', 'name')
|
||||||
->with([
|
->orderBy('name')
|
||||||
'student.user.profile',
|
->get();
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-created registration: additively attach course classes to a
|
* @param array<int, int>|null $ledDepartmentIds Restricts results to a kaprodi's own department(s).
|
||||||
* student's submission for a term, without disturbing an existing
|
|
||||||
* submission's review status.
|
|
||||||
*
|
|
||||||
* @param array{student_id: int, academic_term_id: int, course_class_ids: array<int, int>} $data
|
|
||||||
*/
|
*/
|
||||||
public function create(array $data): CourseRegistrationSubmission
|
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $advisorLecturerId = null, ?array $ledDepartmentIds = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($data) {
|
$paginator = Student::query()
|
||||||
$submission = CourseRegistrationSubmission::firstOrCreate(
|
->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);
|
||||||
|
|
||||||
|
return $paginator->through(fn (Student $student) => [
|
||||||
|
'student_id' => $student->id,
|
||||||
|
'student' => $student,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOrCreateForStudent(Student $student, AcademicTerm $academicTerm, ?int $semesterNumber): CourseRegistrationSubmission
|
||||||
|
{
|
||||||
|
return CourseRegistrationSubmission::firstOrCreate(
|
||||||
[
|
[
|
||||||
'student_id' => $data['student_id'],
|
'student_id' => $student->id,
|
||||||
'academic_term_id' => $data['academic_term_id'],
|
'academic_term_id' => $academicTerm->id,
|
||||||
|
'semester_number' => $semesterNumber,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'status' => RegistrationStatus::Submitted,
|
'status' => RegistrationStatus::Submitted,
|
||||||
'signed_at' => now(),
|
'signed_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($submission->wasRecentlyCreated) {
|
public function findOrCreateForSemester(Student $student, string $semester): CourseRegistrationSubmission
|
||||||
$submission->logs()->create([
|
{
|
||||||
'status' => RegistrationStatus::Submitted,
|
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
|
||||||
'actor_id' => auth()->id(),
|
$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',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($data['course_class_ids'] as $courseClassId) {
|
/**
|
||||||
|
* @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([
|
CourseRegistration::create([
|
||||||
'student_id' => $data['student_id'],
|
'student_id' => $studentId,
|
||||||
'academic_term_id' => $data['academic_term_id'],
|
'academic_term_id' => $academicTermId,
|
||||||
'course_class_id' => $courseClassId,
|
'course_class_id' => $courseClassId,
|
||||||
'submission_id' => $submission->id,
|
'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;
|
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
|
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) {
|
return DB::transaction(function () use ($submission) {
|
||||||
$submission->update([
|
$submission->update([
|
||||||
'status' => RegistrationStatus::Approved,
|
'status' => RegistrationStatus::Approved,
|
||||||
@ -113,6 +276,8 @@ public function approve(CourseRegistrationSubmission $submission): CourseRegistr
|
|||||||
|
|
||||||
public function reject(CourseRegistrationSubmission $submission, string $reason): CourseRegistrationSubmission
|
public function reject(CourseRegistrationSubmission $submission, string $reason): CourseRegistrationSubmission
|
||||||
{
|
{
|
||||||
|
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
|
||||||
|
|
||||||
return DB::transaction(function () use ($submission, $reason) {
|
return DB::transaction(function () use ($submission, $reason) {
|
||||||
$submission->update([
|
$submission->update([
|
||||||
'status' => RegistrationStatus::Rejected,
|
'status' => RegistrationStatus::Rejected,
|
||||||
@ -130,164 +295,4 @@ public function reject(CourseRegistrationSubmission $submission, string $reason)
|
|||||||
return $submission;
|
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class AcademicTermService
|
|||||||
{
|
{
|
||||||
public function getAllForSelect(): Collection
|
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
|
public function paginated(int $perPage = 25, string $search = '', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return AcademicTerm::query()
|
return AcademicTerm::query()
|
||||||
->select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])
|
->select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])
|
||||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
|
->when($search, fn($q) => $q->where('academic_year', 'like', "%{$search}%"))
|
||||||
->when($semester, fn ($q) => $q->where('semester', $semester))
|
->when($semester, fn($q) => $q->where('semester', $semester))
|
||||||
->when($isActive !== null, fn ($q) => $q->where('is_active', $isActive))
|
->when($isActive !== null, fn($q) => $q->where('is_active', $isActive))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): AcademicTerm
|
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
|
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
|
||||||
{
|
{
|
||||||
$academicTerm->name = $data['name'];
|
$academicTerm->academic_year = $data['academic_year'];
|
||||||
$academicTerm->semester = $data['semester'];
|
$academicTerm->semester = $data['semester'];
|
||||||
$academicTerm->start_date = $data['start_date'];
|
$academicTerm->start_date = $data['start_date'];
|
||||||
$academicTerm->end_date = $data['end_date'];
|
$academicTerm->end_date = $data['end_date'];
|
||||||
$academicTerm->is_active = $data['is_active'];
|
$academicTerm->is_active = $data['is_active'];
|
||||||
|
$academicTerm->open_semesters = $this->normalizeOpenSemesters($data['open_semesters'] ?? []);
|
||||||
$academicTerm->update();
|
$academicTerm->update();
|
||||||
|
|
||||||
return $academicTerm;
|
return $academicTerm;
|
||||||
@ -67,4 +71,13 @@ public function updateActiveStatus(AcademicTerm $academicTerm, bool $isActive):
|
|||||||
$academicTerm->update(['is_active' => $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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,14 @@ class CourseService
|
|||||||
{
|
{
|
||||||
public function getAllForSelect(): Collection
|
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
|
public function getSemesterNumbers(): array
|
||||||
@ -29,14 +36,17 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return Course::query()
|
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')
|
->with('department:id,name')
|
||||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('courses.name', 'like', "%{$search}%")->orWhere('courses.code', 'like', "%{$search}%"))
|
||||||
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
|
->when($departmentId, fn ($q) => $q->where('courses.department_id', $departmentId))
|
||||||
->when($semesterNumber, fn ($q) => $q->where('semester_number', $semesterNumber))
|
->when($semesterNumber, fn ($q) => $q->where('courses.semester_number', $semesterNumber))
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('department_id', $user->student?->department_id))
|
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
|
||||||
->when($user->hasRole('dosen'), fn ($q) => $q->whereIn('department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
|
->when($user->hasRole('dosen'), fn ($q) => $q->whereIn('courses.department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
|
||||||
->latest()
|
->orderBy('departments.name')
|
||||||
|
->orderBy('courses.semester_number')
|
||||||
|
->orderBy('courses.name')
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Services\Admin\Master;
|
namespace App\Services\Admin\Master;
|
||||||
|
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
|
use App\Models\DepartmentLeadership;
|
||||||
|
use App\Models\Lecturer;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
@ -17,6 +19,7 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
|
|||||||
{
|
{
|
||||||
return Department::query()
|
return Department::query()
|
||||||
->select(['id', 'code', 'name', 'degree_level'])
|
->select(['id', 'code', 'name', 'degree_level'])
|
||||||
|
->with(['currentLeader.lecturer.user.profile'])
|
||||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
@ -24,7 +27,15 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
|
|||||||
|
|
||||||
public function create(array $data): Department
|
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
|
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->degree_level = $data['degree_level'] ?? null;
|
||||||
$department->update();
|
$department->update();
|
||||||
|
|
||||||
|
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
|
||||||
|
|
||||||
return $department;
|
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
|
public function delete(Department $department): bool
|
||||||
{
|
{
|
||||||
return $department->delete();
|
return $department->delete();
|
||||||
|
|||||||
@ -23,7 +23,7 @@ class PermissionCatalog
|
|||||||
public const MANAGE = [
|
public const MANAGE = [
|
||||||
'view-course-classes', 'create-course-classes', 'update-course-classes', 'delete-course-classes',
|
'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-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-announcements', 'create-announcements', 'update-announcements', 'delete-announcements',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -11,11 +11,12 @@ public function up(): void
|
|||||||
{
|
{
|
||||||
Schema::create('academic_terms', function (Blueprint $table) {
|
Schema::create('academic_terms', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('name', 20);
|
$table->string('academic_year', 20);
|
||||||
$table->enum('semester', Semester::values());
|
$table->enum('semester', Semester::values());
|
||||||
$table->date('start_date');
|
$table->date('start_date');
|
||||||
$table->date('end_date');
|
$table->date('end_date');
|
||||||
$table->boolean('is_active')->default(false);
|
$table->boolean('is_active')->default(false);
|
||||||
|
$table->json('open_semesters')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ public function up(): void
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||||
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->integer('semester_number')->nullable();
|
||||||
$table->enum('status', RegistrationStatus::values())->default(RegistrationStatus::Submitted->value);
|
$table->enum('status', RegistrationStatus::values())->default(RegistrationStatus::Submitted->value);
|
||||||
$table->timestamp('signed_at');
|
$table->timestamp('signed_at');
|
||||||
$table->text('rejection_reason')->nullable();
|
$table->text('rejection_reason')->nullable();
|
||||||
@ -20,7 +21,7 @@ public function up(): void
|
|||||||
$table->timestamp('reviewed_at')->nullable();
|
$table->timestamp('reviewed_at')->nullable();
|
||||||
$table->timestamps();
|
$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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ public function run(): void
|
|||||||
{
|
{
|
||||||
AcademicTerm::insert([
|
AcademicTerm::insert([
|
||||||
[
|
[
|
||||||
'name' => '2026/2027',
|
'academic_year' => '2026/2027',
|
||||||
'semester' => Semester::odd->value,
|
'semester' => Semester::odd->value,
|
||||||
'start_date' => '2026-08-01',
|
'start_date' => '2026-08-01',
|
||||||
'end_date' => '2027-01-15',
|
'end_date' => '2027-01-15',
|
||||||
@ -21,7 +21,7 @@ public function run(): void
|
|||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => '2026/2027',
|
'academic_year' => '2026/2027',
|
||||||
'semester' => Semester::even->value,
|
'semester' => Semester::even->value,
|
||||||
'start_date' => '2027-02-01',
|
'start_date' => '2027-02-01',
|
||||||
'end_date' => '2027-07-15',
|
'end_date' => '2027-07-15',
|
||||||
|
|||||||
@ -143,6 +143,8 @@ public function run(): void
|
|||||||
['code' => 'MBT707', 'name' => 'KKN Tematik 1', 'credits' => 4, 'department_id' => 2, 'semester_number' => 7],
|
['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' => '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
|
// 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' => '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],
|
['code' => 'BDU102', 'name' => 'Pendidikan Anti Korupsi', 'credits' => 2, 'department_id' => 3, 'semester_number' => 1],
|
||||||
|
|||||||
@ -11,11 +11,16 @@ class DatabaseSeeder extends Seeder
|
|||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->call([
|
$seeders = [
|
||||||
RolePermissionSeeder::class,
|
RolePermissionSeeder::class,
|
||||||
DepartmentSeeder::class,
|
DepartmentSeeder::class,
|
||||||
AcademicTermSeeder::class,
|
AcademicTermSeeder::class,
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (app()->environment('local')) {
|
||||||
|
$seeders = [
|
||||||
|
...$seeders,
|
||||||
DepartmentLeadershipSeeder::class,
|
DepartmentLeadershipSeeder::class,
|
||||||
CourseSeeder::class,
|
CourseSeeder::class,
|
||||||
CourseClassSeeder::class,
|
CourseClassSeeder::class,
|
||||||
@ -32,6 +37,9 @@ public function run(): void
|
|||||||
LetterRequestSeeder::class,
|
LetterRequestSeeder::class,
|
||||||
AcademicAdvisingLogSeeder::class,
|
AcademicAdvisingLogSeeder::class,
|
||||||
NotificationSeeder::class,
|
NotificationSeeder::class,
|
||||||
]);
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->call($seeders);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ public function run(): void
|
|||||||
'view-dashboard',
|
'view-dashboard',
|
||||||
'view-academic-terms',
|
'view-academic-terms',
|
||||||
'view-courses',
|
'view-courses',
|
||||||
|
'view-course-registrations',
|
||||||
'view-letter-requests',
|
'view-letter-requests',
|
||||||
'create-letter-requests',
|
'create-letter-requests',
|
||||||
'update-letter-requests',
|
'update-letter-requests',
|
||||||
@ -52,6 +53,9 @@ public function run(): void
|
|||||||
'view-courses',
|
'view-courses',
|
||||||
'view-academic-advising-logs',
|
'view-academic-advising-logs',
|
||||||
'view-students',
|
'view-students',
|
||||||
|
'view-course-registrations',
|
||||||
|
'approve-course-registrations',
|
||||||
|
'reject-course-registrations',
|
||||||
...$feedbackSelfService,
|
...$feedbackSelfService,
|
||||||
],
|
],
|
||||||
'staff-admin' => [
|
'staff-admin' => [
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Enums\Gender;
|
||||||
use App\Enums\Semester;
|
use App\Enums\Semester;
|
||||||
use App\Enums\StudentStatus;
|
use App\Enums\StudentStatus;
|
||||||
use App\Models\AcademicTerm;
|
use App\Models\AcademicTerm;
|
||||||
@ -20,86 +21,28 @@ public function run(): void
|
|||||||
$users = [
|
$users = [
|
||||||
[
|
[
|
||||||
'username' => 'pangestu',
|
'username' => 'pangestu',
|
||||||
'email' => 'pangestu@student.itmpwk.ac.id',
|
'email' => 'project.pangestuyoga@gmail.com',
|
||||||
'roles' => ['mahasiswa', 'developer'],
|
'roles' => ['developer'],
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'full_name' => 'Yoga Pangestu',
|
'full_name' => 'Yoga Pangestu',
|
||||||
'phone_number' => '082121495806',
|
'phone_number' => '082121495806',
|
||||||
'address' => 'Jl. Merdeka No. 1, Purwakarta',
|
'gender' => Gender::Male,
|
||||||
'gender' => 'male',
|
|
||||||
'birth_place' => 'Subang',
|
'birth_place' => 'Subang',
|
||||||
'birth_date' => '2005-03-13',
|
'birth_date' => '2005-03-13',
|
||||||
],
|
'address' => 'Jl. Raya Cijambe No. 123, Subang, Jawa Barat',
|
||||||
'student' => [
|
|
||||||
'student_number' => '23010001',
|
|
||||||
'department_id' => 1,
|
|
||||||
'enrollment_year' => 2023,
|
|
||||||
'current_semester' => 5,
|
|
||||||
'academic_advisor_id' => null,
|
|
||||||
'status' => StudentStatus::Active,
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'username' => 'doni',
|
'username' => 'administrator',
|
||||||
'email' => 'doni@student.itmpwk.ac.id',
|
'email' => 'administrator@gmail.com',
|
||||||
'roles' => ['mahasiswa'],
|
'roles' => ['staff-admin'],
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'full_name' => 'Doni Setiawan Ramadhan',
|
'full_name' => 'Administrator',
|
||||||
'phone_number' => '085793462823',
|
'phone_number' => '082121212121',
|
||||||
'address' => 'Jl. Merdeka No. 1, Purwakarta',
|
'gender' => Gender::Male,
|
||||||
'gender' => 'male',
|
|
||||||
'birth_place' => 'Purwakarta',
|
'birth_place' => 'Purwakarta',
|
||||||
'birth_date' => '2004-10-30',
|
'birth_date' => '2000-01-01',
|
||||||
],
|
|
||||||
'student' => [
|
|
||||||
'student_number' => '23020002',
|
|
||||||
'department_id' => 1,
|
|
||||||
'enrollment_year' => 2023,
|
|
||||||
'current_semester' => 5,
|
|
||||||
'academic_advisor_id' => null,
|
|
||||||
'status' => StudentStatus::Active,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'username' => 'asep',
|
|
||||||
'email' => 'asep@student.itmpwk.ac.id',
|
|
||||||
'roles' => ['mahasiswa'],
|
|
||||||
'profile' => [
|
|
||||||
'full_name' => 'Asep Saepudin',
|
|
||||||
'phone_number' => '081546505033',
|
|
||||||
'address' => 'Jl. Merdeka No. 1, Purwakarta',
|
'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,10 +78,12 @@ public function run(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (app()->environment('local')) {
|
||||||
$this->seedRandomLecturers();
|
$this->seedRandomLecturers();
|
||||||
$this->seedRandomStudents();
|
$this->seedRandomStudents();
|
||||||
$this->seedOtherStaff();
|
$this->seedOtherStaff();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 5-8 dosen acak per jurusan. Sebagian dosen diafiliasikan ke lebih dari
|
* 5-8 dosen acak per jurusan. Sebagian dosen diafiliasikan ke lebih dari
|
||||||
@ -193,7 +138,7 @@ private function seedRandomStudents(): void
|
|||||||
$departments = Department::all();
|
$departments = Department::all();
|
||||||
|
|
||||||
foreach (AcademicTerm::all() as $term) {
|
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;
|
$enrollmentYear = $term->semester === Semester::even ? (int) $endYear : (int) $startYear;
|
||||||
|
|
||||||
foreach ($departments as $department) {
|
foreach ($departments as $department) {
|
||||||
|
|||||||
@ -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 administratorsRoute } from '@/routes/admin/users/administrators';
|
||||||
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
||||||
import { index as studentsRoute } from '@/routes/admin/users/students';
|
import { index as studentsRoute } from '@/routes/admin/users/students';
|
||||||
import { index as studentCourseRegistrationsRoute } from '@/routes/student/course-registrations';
|
|
||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
|
|
||||||
const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
|
|
||||||
|
|
||||||
function buildNavMain({
|
function buildNavMain({
|
||||||
isMahasiswa,
|
|
||||||
permissions,
|
permissions,
|
||||||
}: {
|
}: {
|
||||||
isMahasiswa: boolean;
|
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}): (NavGroup | NavItem)[] {
|
}): (NavGroup | NavItem)[] {
|
||||||
const can = (permission: string) => permissions.includes(permission);
|
const can = (permission: string) => permissions.includes(permission);
|
||||||
@ -117,17 +112,6 @@ function buildNavMain({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const kelolaItems: NavItem[] = [
|
const kelolaItems: NavItem[] = [
|
||||||
...(isMahasiswa || can('view-course-registrations')
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: 'Registrasi KRS',
|
|
||||||
url: isMahasiswa
|
|
||||||
? studentCourseRegistrationsRoute.url()
|
|
||||||
: courseRegistrationsRoute.url(),
|
|
||||||
icon: FileCheck2,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(can('view-course-classes')
|
...(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')
|
...(can('view-announcements')
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@ -289,14 +282,8 @@ function buildNavSecondary(
|
|||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { name, auth, pendingFeedbackCount } = usePage<{ auth: Auth }>()
|
const { name, auth, pendingFeedbackCount } = usePage<{ auth: Auth }>()
|
||||||
.props;
|
.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 permissions = auth?.permissions ?? [];
|
||||||
const navMain = buildNavMain({
|
const navMain = buildNavMain({ permissions });
|
||||||
isMahasiswa,
|
|
||||||
permissions,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
|
|||||||
@ -65,6 +65,7 @@ interface DataTableProps<TData, TValue> {
|
|||||||
toolbar?: React.ReactNode;
|
toolbar?: React.ReactNode;
|
||||||
renderSubRow?: (row: Row<TData>, searchValue?: string) => React.ReactNode;
|
renderSubRow?: (row: Row<TData>, searchValue?: string) => React.ReactNode;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
|
groupBy?: (item: TData) => string;
|
||||||
pagination?: PaginationState;
|
pagination?: PaginationState;
|
||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
onPerPageChange?: (perPage: number) => void;
|
onPerPageChange?: (perPage: number) => void;
|
||||||
@ -156,6 +157,7 @@ export function DataTable<TData, TValue>({
|
|||||||
toolbar,
|
toolbar,
|
||||||
renderSubRow,
|
renderSubRow,
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
|
groupBy,
|
||||||
pagination,
|
pagination,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onPerPageChange,
|
onPerPageChange,
|
||||||
@ -404,8 +406,37 @@ export function DataTable<TData, TValue>({
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
) : (
|
) : (
|
||||||
table.getRowModel().rows.map((row) => (
|
(() => {
|
||||||
<React.Fragment key={row.id}>
|
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="py-2 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{group}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={
|
data-state={
|
||||||
row.getIsSelected() &&
|
row.getIsSelected() &&
|
||||||
@ -414,27 +445,34 @@ export function DataTable<TData, TValue>({
|
|||||||
>
|
>
|
||||||
{row
|
{row
|
||||||
.getVisibleCells()
|
.getVisibleCells()
|
||||||
.map((cell) => (
|
.map(
|
||||||
|
(cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={
|
||||||
|
cell.id
|
||||||
|
}
|
||||||
className={
|
className={
|
||||||
(
|
(
|
||||||
cell.column
|
cell
|
||||||
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.meta as {
|
.meta as {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
)?.className
|
)
|
||||||
|
?.className
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column
|
cell
|
||||||
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.cell,
|
.cell,
|
||||||
cell.getContext(),
|
cell.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{renderSubRow &&
|
{renderSubRow &&
|
||||||
row.getIsExpanded() && (
|
row.getIsExpanded() && (
|
||||||
@ -455,7 +493,9 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
);
|
||||||
|
});
|
||||||
|
})()
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@ -19,13 +19,17 @@ type DatePickerProps = {
|
|||||||
maxDate?: Date;
|
maxDate?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultMaxDate = new Date(
|
||||||
|
new Date().setFullYear(new Date().getFullYear() + 5),
|
||||||
|
);
|
||||||
|
|
||||||
export function DatePicker({
|
export function DatePicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Pilih tanggal',
|
placeholder = 'Pilih tanggal',
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
maxDate,
|
maxDate = defaultMaxDate,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
@ -50,6 +54,7 @@ export function DatePicker({
|
|||||||
defaultMonth={value ?? undefined}
|
defaultMonth={value ?? undefined}
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
onSelect={onChange}
|
onSelect={onChange}
|
||||||
|
endMonth={maxDate}
|
||||||
disabled={maxDate ? { after: maxDate } : undefined}
|
disabled={maxDate ? { after: maxDate } : undefined}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export function FormDialog({
|
|||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</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'
|
{typeof children === 'function'
|
||||||
? children({ errors, processing })
|
? children({ errors, processing })
|
||||||
: children}
|
: children}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Eraser } from 'lucide-react';
|
import { Eraser } from 'lucide-react';
|
||||||
import { useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import SignatureCanvasImport from 'react-signature-canvas';
|
import SignatureCanvasImport from 'react-signature-canvas';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -18,12 +18,49 @@ const SignatureCanvas = ((
|
|||||||
type SignaturePadProps = {
|
type SignaturePadProps = {
|
||||||
name: string;
|
name: string;
|
||||||
error?: 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 padRef = useRef<SignatureCanvasImport>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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() {
|
function syncFileInput() {
|
||||||
const pad = padRef.current;
|
const pad = padRef.current;
|
||||||
const input = fileInputRef.current;
|
const input = fileInputRef.current;
|
||||||
@ -43,6 +80,7 @@ export function SignaturePad({ name, error }: SignaturePadProps) {
|
|||||||
const dataTransfer = new DataTransfer();
|
const dataTransfer = new DataTransfer();
|
||||||
dataTransfer.items.add(file);
|
dataTransfer.items.add(file);
|
||||||
input.files = dataTransfer.files;
|
input.files = dataTransfer.files;
|
||||||
|
onCapture?.(file);
|
||||||
}, 'image/png');
|
}, 'image/png');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +90,41 @@ export function SignaturePad({ name, error }: SignaturePadProps) {
|
|||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
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 (
|
return (
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export function createTuitionInvoiceColumns(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'academic_term.name',
|
accessorKey: 'academic_term.academic_year',
|
||||||
header: () => <span>Periode</span>,
|
header: () => <span>Periode</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const term = row.original.academic_term;
|
const term = row.original.academic_term;
|
||||||
|
|||||||
@ -69,7 +69,11 @@ import {
|
|||||||
} from '@/types/tuition-payment';
|
} from '@/types/tuition-payment';
|
||||||
import { createTuitionInvoiceColumns } from './columns';
|
import { createTuitionInvoiceColumns } from './columns';
|
||||||
|
|
||||||
type AcademicTermOption = { id: number; name: string; semester: string };
|
type AcademicTermOption = {
|
||||||
|
id: number;
|
||||||
|
academic_year: string;
|
||||||
|
semester: string;
|
||||||
|
};
|
||||||
|
|
||||||
type InvoiceSummary = {
|
type InvoiceSummary = {
|
||||||
unpaid_students: number;
|
unpaid_students: number;
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { Pencil, Trash2, Users } from 'lucide-react';
|
|||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { index as enrollmentsIndex } from '@/routes/admin/manage/course-classes/enrollments';
|
import { index as enrollmentsIndex } from '@/routes/admin/manage/course-classes/enrollments';
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
||||||
import { ClassMethodLabels } from '@/types/course-class';
|
import { ClassMethodLabels } from '@/types/course-class';
|
||||||
import type { CourseClass } from '@/types/course-class';
|
import type { CourseClass } from '@/types/course-class';
|
||||||
|
|
||||||
@ -44,15 +43,6 @@ export function createCourseClassColumns(
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.lecturer?.user?.profile?.full_name ?? '-',
|
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',
|
accessorKey: 'method',
|
||||||
header: () => <span className="block text-center">Metode</span>,
|
header: () => <span className="block text-center">Metode</span>,
|
||||||
|
|||||||
@ -1,47 +1,33 @@
|
|||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ArrowLeft, Trash2, UserPlus } from 'lucide-react';
|
import { ArrowLeft, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
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 { PageHeader } from '@/components/page-header';
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { index as courseClassIndex } from '@/routes/admin/manage/course-classes';
|
import { index as courseClassIndex } from '@/routes/admin/manage/course-classes';
|
||||||
import {
|
import { destroy } from '@/routes/admin/manage/course-classes/enrollments';
|
||||||
destroy,
|
|
||||||
store,
|
|
||||||
} from '@/routes/admin/manage/course-classes/enrollments';
|
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
import type {
|
import type { ClassEnrollment } from '@/types/class-enrollment';
|
||||||
ClassEnrollment,
|
|
||||||
EnrollmentStudent,
|
|
||||||
} from '@/types/class-enrollment';
|
|
||||||
import type { CourseClass } from '@/types/course-class';
|
import type { CourseClass } from '@/types/course-class';
|
||||||
import { ClassMethodLabels } from '@/types/course-class';
|
import { ClassMethodLabels } from '@/types/course-class';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
courseClass: CourseClass;
|
courseClass: CourseClass;
|
||||||
enrollments: ClassEnrollment[];
|
enrollments: ClassEnrollment[];
|
||||||
availableStudents: EnrollmentStudent[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClassEnrollmentIndex({
|
export default function ClassEnrollmentIndex({
|
||||||
courseClass,
|
courseClass,
|
||||||
enrollments,
|
enrollments,
|
||||||
availableStudents,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [deleting, setDeleting] = useState<ClassEnrollment | null>(null);
|
const [deleting, setDeleting] = useState<ClassEnrollment | null>(null);
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
const canCreate = hasPermission('create-course-class-enrollments');
|
|
||||||
const canDelete = hasPermission('delete-course-class-enrollments');
|
const canDelete = hasPermission('delete-course-class-enrollments');
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -158,25 +144,10 @@ export default function ClassEnrollmentIndex({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
|
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
|
||||||
{canCreate && (
|
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
|
||||||
<UserPlus className="h-4 w-4" />
|
|
||||||
Tambah Mahasiswa
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable columns={columns} data={enrollments} />
|
<DataTable columns={columns} data={enrollments} />
|
||||||
|
|
||||||
<EnrollForm
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
courseClassId={courseClass.id}
|
|
||||||
availableStudents={availableStudents}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
target={deleting}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Copy, Plus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
@ -12,11 +12,18 @@ import { PageHeader } from '@/components/page-header';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxCollection,
|
||||||
ComboboxContent,
|
ComboboxContent,
|
||||||
ComboboxEmpty,
|
ComboboxEmpty,
|
||||||
|
ComboboxGroup,
|
||||||
ComboboxInput,
|
ComboboxInput,
|
||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
|
ComboboxLabel,
|
||||||
ComboboxList,
|
ComboboxList,
|
||||||
|
useComboboxAnchor,
|
||||||
} from '@/components/ui/combobox';
|
} from '@/components/ui/combobox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
@ -31,6 +38,7 @@ import { useServerTable } from '@/hooks/use-server-table';
|
|||||||
import {
|
import {
|
||||||
index as courseClassIndex,
|
index as courseClassIndex,
|
||||||
destroy,
|
destroy,
|
||||||
|
duplicate,
|
||||||
store,
|
store,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/manage/course-classes';
|
} 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 type { CourseClass } from '@/types/course-class';
|
||||||
import { createCourseClassColumns } from './columns';
|
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 = {
|
type Lecturer = {
|
||||||
id: number;
|
id: number;
|
||||||
lecturer_number: string;
|
lecturer_number: string;
|
||||||
departments: { id: number; name: string }[];
|
departments: { id: number; name: string }[];
|
||||||
user: { profile: { full_name: string } | null } | null;
|
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 = {
|
type Props = {
|
||||||
courseClasses: {
|
courseClasses: {
|
||||||
@ -59,6 +115,7 @@ type Props = {
|
|||||||
courses: Course[];
|
courses: Course[];
|
||||||
lecturers: Lecturer[];
|
lecturers: Lecturer[];
|
||||||
academicTerms: AcademicTerm[];
|
academicTerms: AcademicTerm[];
|
||||||
|
courseClassAssignments: CourseClassAssignment[];
|
||||||
highlight?: number;
|
highlight?: number;
|
||||||
filters: {
|
filters: {
|
||||||
academic_term_id?: string;
|
academic_term_id?: string;
|
||||||
@ -71,10 +128,12 @@ export default function CourseClassIndex({
|
|||||||
courses,
|
courses,
|
||||||
lecturers,
|
lecturers,
|
||||||
academicTerms,
|
academicTerms,
|
||||||
|
courseClassAssignments,
|
||||||
highlight,
|
highlight,
|
||||||
filters,
|
filters,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<CourseClass | null>(null);
|
const [editing, setEditing] = useState<CourseClass | null>(null);
|
||||||
const [deleting, setDeleting] = useState<CourseClass | null>(null);
|
const [deleting, setDeleting] = useState<CourseClass | null>(null);
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
@ -170,6 +229,14 @@ export default function CourseClassIndex({
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
canCreate && (
|
canCreate && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDuplicateOpen(true)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Duplikat dari Periode
|
||||||
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -179,6 +246,7 @@ export default function CourseClassIndex({
|
|||||||
Tambah
|
Tambah
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -189,6 +257,13 @@ export default function CourseClassIndex({
|
|||||||
courses={courses}
|
courses={courses}
|
||||||
lecturers={lecturers}
|
lecturers={lecturers}
|
||||||
academicTerms={academicTerms}
|
academicTerms={academicTerms}
|
||||||
|
courseClassAssignments={courseClassAssignments}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DuplicateForm
|
||||||
|
open={duplicateOpen}
|
||||||
|
onOpenChange={setDuplicateOpen}
|
||||||
|
academicTerms={academicTerms}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EditForm
|
<EditForm
|
||||||
@ -203,6 +278,7 @@ export default function CourseClassIndex({
|
|||||||
courses={courses}
|
courses={courses}
|
||||||
lecturers={lecturers}
|
lecturers={lecturers}
|
||||||
academicTerms={academicTerms}
|
academicTerms={academicTerms}
|
||||||
|
courseClassAssignments={courseClassAssignments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -214,6 +290,9 @@ export default function CourseClassIndex({
|
|||||||
onPerPageChange={handlePerPageChange}
|
onPerPageChange={handlePerPageChange}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
|
groupBy={(courseClass) =>
|
||||||
|
`${courseClass.academic_term ? formatAcademicTermLabel(courseClass.academic_term) : 'Tanpa Periode'} — Semester ${courseClass.course?.semester_number ?? 'Tidak ditentukan'}`
|
||||||
|
}
|
||||||
toolbar={
|
toolbar={
|
||||||
<FilterDialog
|
<FilterDialog
|
||||||
fields={filterFields}
|
fields={filterFields}
|
||||||
@ -247,28 +326,46 @@ function CreateForm({
|
|||||||
courses,
|
courses,
|
||||||
lecturers,
|
lecturers,
|
||||||
academicTerms,
|
academicTerms,
|
||||||
|
courseClassAssignments,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
courses: Course[];
|
courses: Course[];
|
||||||
lecturers: Lecturer[];
|
lecturers: Lecturer[];
|
||||||
academicTerms: AcademicTerm[];
|
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 [lecturer, setLecturer] = useState<Lecturer | null>(null);
|
||||||
|
const courseAnchor = useComboboxAnchor();
|
||||||
|
|
||||||
const selectedCourse = courses.find((c) => String(c.id) === courseId);
|
const selectedTerm = academicTerms.find(
|
||||||
const availableLecturers = selectedCourse
|
(term) => String(term.id) === academicTermId,
|
||||||
? lecturers.filter((l) =>
|
);
|
||||||
l.departments.some(
|
const takenCourseIds = new Set(
|
||||||
(department) =>
|
courseClassAssignments
|
||||||
department.id === selectedCourse.department_id,
|
.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() {
|
function reset() {
|
||||||
setCourseId('');
|
setAcademicTermId('');
|
||||||
|
setSelectedCourses([]);
|
||||||
setLecturer(null);
|
setLecturer(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,8 +388,18 @@ function CreateForm({
|
|||||||
Periode Akademik{' '}
|
Periode Akademik{' '}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<input type="hidden" name="academic_term_id" />
|
<input
|
||||||
<Select name="academic_term_id">
|
type="hidden"
|
||||||
|
name="academic_term_id"
|
||||||
|
value={academicTermId}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={academicTermId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setAcademicTermId(value);
|
||||||
|
setSelectedCourses([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Pilih periode akademik" />
|
<SelectValue placeholder="Pilih periode akademik" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -309,39 +416,6 @@ function CreateForm({
|
|||||||
</Select>
|
</Select>
|
||||||
<InputError message={errors.academic_term_id} />
|
<InputError message={errors.academic_term_id} />
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Dosen Pengampu{' '}
|
Dosen Pengampu{' '}
|
||||||
@ -353,22 +427,19 @@ function CreateForm({
|
|||||||
value={lecturer?.id ?? ''}
|
value={lecturer?.id ?? ''}
|
||||||
/>
|
/>
|
||||||
<Combobox
|
<Combobox
|
||||||
items={availableLecturers}
|
items={lecturers}
|
||||||
value={lecturer}
|
value={lecturer}
|
||||||
onValueChange={setLecturer}
|
onValueChange={(value) => {
|
||||||
|
setLecturer(value);
|
||||||
|
setSelectedCourses([]);
|
||||||
|
}}
|
||||||
itemToStringLabel={(lect) =>
|
itemToStringLabel={(lect) =>
|
||||||
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
||||||
}
|
}
|
||||||
isItemEqualToValue={(a, b) => a.id === b.id}
|
isItemEqualToValue={(a, b) => a.id === b.id}
|
||||||
disabled={!selectedCourse}
|
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
disabled={!selectedCourse}
|
placeholder="Pilih dosen pengampu"
|
||||||
placeholder={
|
|
||||||
selectedCourse
|
|
||||||
? 'Pilih dosen pengampu'
|
|
||||||
: 'Pilih mata kuliah terlebih dahulu'
|
|
||||||
}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
@ -391,6 +462,78 @@ function CreateForm({
|
|||||||
</Combobox>
|
</Combobox>
|
||||||
<InputError message={errors.lecturer_id} />
|
<InputError message={errors.lecturer_id} />
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>Metode</Label>
|
<Label>Metode</Label>
|
||||||
<input type="hidden" name="method" />
|
<input type="hidden" name="method" />
|
||||||
@ -421,6 +564,7 @@ function EditForm({
|
|||||||
courses,
|
courses,
|
||||||
lecturers,
|
lecturers,
|
||||||
academicTerms,
|
academicTerms,
|
||||||
|
courseClassAssignments,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@ -428,9 +572,15 @@ function EditForm({
|
|||||||
courses: Course[];
|
courses: Course[];
|
||||||
lecturers: Lecturer[];
|
lecturers: Lecturer[];
|
||||||
academicTerms: AcademicTerm[];
|
academicTerms: AcademicTerm[];
|
||||||
|
courseClassAssignments: CourseClassAssignment[];
|
||||||
}) {
|
}) {
|
||||||
const [courseId, setCourseId] = useState(
|
const [academicTermId, setAcademicTermId] = useState(
|
||||||
editing ? String(editing.course_id) : '',
|
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>(
|
const [lecturer, setLecturer] = useState<Lecturer | null>(
|
||||||
editing
|
editing
|
||||||
@ -438,15 +588,30 @@ function EditForm({
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedCourse = courses.find((c) => String(c.id) === courseId);
|
const selectedTerm = academicTerms.find(
|
||||||
const availableLecturers = selectedCourse
|
(term) => String(term.id) === academicTermId,
|
||||||
? lecturers.filter((l) =>
|
);
|
||||||
l.departments.some(
|
const takenCourseIds = new Set(
|
||||||
(department) =>
|
courseClassAssignments
|
||||||
department.id === selectedCourse.department_id,
|
.filter(
|
||||||
),
|
(a) =>
|
||||||
|
String(a.academic_term_id) === academicTermId &&
|
||||||
|
a.id !== editing?.id,
|
||||||
)
|
)
|
||||||
: lecturers;
|
.map((a) => a.course_id),
|
||||||
|
);
|
||||||
|
const lecturerDepartmentIds = new Set(
|
||||||
|
lecturer ? lecturer.departments.map((d) => d.id) : [],
|
||||||
|
);
|
||||||
|
const availableCourses = lecturer
|
||||||
|
? courses.filter(
|
||||||
|
(c) =>
|
||||||
|
lecturerDepartmentIds.has(c.department_id) &&
|
||||||
|
!takenCourseIds.has(c.id) &&
|
||||||
|
courseMatchesTermParity(c, selectedTerm),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -462,36 +627,36 @@ function EditForm({
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Mata Kuliah{' '}
|
Periode Akademik{' '}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="course_id"
|
name="academic_term_id"
|
||||||
value={courseId}
|
value={academicTermId}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={courseId}
|
value={academicTermId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setCourseId(value);
|
setAcademicTermId(value);
|
||||||
setLecturer(null);
|
setCourse(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Pilih mata kuliah" />
|
<SelectValue placeholder="Pilih periode akademik" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{courses.map((course) => (
|
{academicTerms.map((term) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={course.id}
|
key={term.id}
|
||||||
value={String(course.id)}
|
value={String(term.id)}
|
||||||
>
|
>
|
||||||
{course.code} - {course.name}
|
{formatAcademicTermLabel(term)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<InputError message={errors.course_id} />
|
<InputError message={errors.academic_term_id} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
@ -504,22 +669,19 @@ function EditForm({
|
|||||||
value={lecturer?.id ?? ''}
|
value={lecturer?.id ?? ''}
|
||||||
/>
|
/>
|
||||||
<Combobox
|
<Combobox
|
||||||
items={availableLecturers}
|
items={lecturers}
|
||||||
value={lecturer}
|
value={lecturer}
|
||||||
onValueChange={setLecturer}
|
onValueChange={(value) => {
|
||||||
|
setLecturer(value);
|
||||||
|
setCourse(null);
|
||||||
|
}}
|
||||||
itemToStringLabel={(lect) =>
|
itemToStringLabel={(lect) =>
|
||||||
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
||||||
}
|
}
|
||||||
isItemEqualToValue={(a, b) => a.id === b.id}
|
isItemEqualToValue={(a, b) => a.id === b.id}
|
||||||
disabled={!selectedCourse}
|
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
disabled={!selectedCourse}
|
placeholder="Pilih dosen pengampu"
|
||||||
placeholder={
|
|
||||||
selectedCourse
|
|
||||||
? 'Pilih dosen pengampu'
|
|
||||||
: 'Pilih mata kuliah terlebih dahulu'
|
|
||||||
}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
@ -544,28 +706,63 @@ function EditForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Periode Akademik{' '}
|
Mata Kuliah{' '}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<input
|
||||||
name="academic_term_id"
|
type="hidden"
|
||||||
defaultValue={String(editing.academic_term_id)}
|
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">
|
<ComboboxInput
|
||||||
<SelectValue placeholder="Pilih periode akademik" />
|
disabled={!lecturer}
|
||||||
</SelectTrigger>
|
placeholder={
|
||||||
<SelectContent>
|
lecturer
|
||||||
{academicTerms.map((term) => (
|
? 'Pilih mata kuliah'
|
||||||
<SelectItem
|
: 'Pilih dosen pengampu terlebih dahulu'
|
||||||
key={term.id}
|
}
|
||||||
value={String(term.id)}
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ComboboxContent>
|
||||||
|
<ComboboxEmpty>
|
||||||
|
Mata kuliah tidak ditemukan.
|
||||||
|
</ComboboxEmpty>
|
||||||
|
<ComboboxList>
|
||||||
|
{(group: CourseGroup) => (
|
||||||
|
<ComboboxGroup
|
||||||
|
key={group.value}
|
||||||
|
items={group.items}
|
||||||
>
|
>
|
||||||
{formatAcademicTermLabel(term)}
|
<ComboboxLabel>
|
||||||
</SelectItem>
|
{group.value}
|
||||||
))}
|
</ComboboxLabel>
|
||||||
</SelectContent>
|
<ComboboxCollection>
|
||||||
</Select>
|
{(c: Course) => (
|
||||||
<InputError message={errors.academic_term_id} />
|
<ComboboxItem
|
||||||
|
key={c.id}
|
||||||
|
value={c}
|
||||||
|
>
|
||||||
|
{c.code} -{' '}
|
||||||
|
{c.name}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
</ComboboxCollection>
|
||||||
|
</ComboboxGroup>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
<InputError message={errors.course_id} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Metode</Label>
|
<Label>Metode</Label>
|
||||||
@ -592,3 +789,133 @@ function EditForm({
|
|||||||
</FormDialog>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,25 +1,12 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
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 { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { show } from '@/routes/admin/manage/course-registrations';
|
import { show } from '@/routes/admin/manage/course-registrations';
|
||||||
import type { CourseRegistrationSubmission } from '@/types/course-registration';
|
import type { CourseRegistrationRow } from '@/types/course-registration';
|
||||||
import { RegistrationStatusLabels } from '@/types/course-registration';
|
|
||||||
|
|
||||||
export type { CourseRegistrationSubmission } from '@/types/course-registration';
|
export type { CourseRegistrationRow } from '@/types/course-registration';
|
||||||
|
|
||||||
type CreateColumnsParams = {
|
|
||||||
handleApprove: (submission: CourseRegistrationSubmission) => void;
|
|
||||||
handleRejectClick: (submission: CourseRegistrationSubmission) => void;
|
|
||||||
canApprove: boolean;
|
|
||||||
canReject: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createCourseRegistrationColumns(
|
|
||||||
params: CreateColumnsParams,
|
|
||||||
): ColumnDef<CourseRegistrationSubmission>[] {
|
|
||||||
const { handleApprove, handleRejectClick, canApprove, canReject } = params;
|
|
||||||
|
|
||||||
|
export function createCourseRegistrationColumns(): ColumnDef<CourseRegistrationRow>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'student',
|
id: 'student',
|
||||||
@ -33,98 +20,35 @@ export function createCourseRegistrationColumns(
|
|||||||
{student?.user?.profile?.full_name ?? 'N/A'}
|
{student?.user?.profile?.full_name ?? 'N/A'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{student?.student_number} ·{' '}
|
{student?.student_number}
|
||||||
{student?.department?.name ?? '-'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'semester',
|
id: 'department',
|
||||||
header: () => <span className="block text-center">Semester</span>,
|
header: () => <span>Jurusan</span>,
|
||||||
meta: {
|
cell: ({ row }) => row.original.student?.department?.name ?? '-',
|
||||||
className: 'w-[100px] text-center',
|
|
||||||
headerClassName: 'w-[100px] text-center',
|
|
||||||
},
|
|
||||||
cell: ({ row }) => row.original.student?.current_semester ?? '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'count',
|
|
||||||
header: () => <span className="block text-center">Jumlah</span>,
|
|
||||||
meta: {
|
|
||||||
className: 'w-[100px] text-center',
|
|
||||||
headerClassName: 'w-[100px] text-center',
|
|
||||||
},
|
|
||||||
cell: ({ row }) => row.original.course_registrations.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: () => <span className="block text-center">Status</span>,
|
|
||||||
meta: {
|
|
||||||
className: 'w-[140px] text-center',
|
|
||||||
headerClassName: 'w-[140px] text-center',
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const status = row.original.status;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
status === 'approved'
|
|
||||||
? 'default'
|
|
||||||
: status === 'rejected'
|
|
||||||
? 'destructive'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{RegistrationStatusLabels[status]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <span className="block text-center">Aksi</span>,
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[130px] text-center',
|
className: 'w-[80px] text-center',
|
||||||
headerClassName: 'w-[130px] text-center',
|
headerClassName: 'w-[80px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const submission = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
label: 'Lihat Detail',
|
label: 'Lihat Detail',
|
||||||
icon: <Eye className="h-4 w-4" />,
|
icon: <Eye className="h-4 w-4" />,
|
||||||
href: show.url(submission.id),
|
href: show.url(row.original.student_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),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,118 +1,43 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { FilterField } from '@/components/filter-dialog';
|
import type { FilterField } from '@/components/filter-dialog';
|
||||||
import { FilterDialog } from '@/components/filter-dialog';
|
import { FilterDialog } from '@/components/filter-dialog';
|
||||||
import { FormDialog } from '@/components/form-dialog';
|
|
||||||
import InputError from '@/components/input-error';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
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 { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import { index } from '@/routes/admin/manage/course-registrations';
|
||||||
index as courseRegistrationIndex,
|
|
||||||
approve,
|
|
||||||
reject,
|
|
||||||
store,
|
|
||||||
} from '@/routes/admin/manage/course-registrations';
|
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
||||||
import type {
|
import type {
|
||||||
CourseRegistrationCourseClass,
|
CourseRegistrationRow,
|
||||||
CourseRegistrationStudent,
|
DepartmentSummary,
|
||||||
CourseRegistrationSubmission,
|
|
||||||
} from '@/types/course-registration';
|
|
||||||
import {
|
|
||||||
RegistrationStatuses,
|
|
||||||
RegistrationStatusLabels,
|
|
||||||
} from '@/types/course-registration';
|
} from '@/types/course-registration';
|
||||||
import { createCourseRegistrationColumns } from './columns';
|
import { createCourseRegistrationColumns } from './columns';
|
||||||
|
|
||||||
type AcademicTermOption = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
semester: string;
|
|
||||||
is_active: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
departments: DepartmentSummary[];
|
||||||
registrations: {
|
registrations: {
|
||||||
data: CourseRegistrationSubmission[];
|
data: CourseRegistrationRow[];
|
||||||
current_page: number;
|
current_page: number;
|
||||||
last_page: number;
|
last_page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
students: CourseRegistrationStudent[];
|
|
||||||
academicTerms: AcademicTermOption[];
|
|
||||||
courseClasses: CourseRegistrationCourseClass[];
|
|
||||||
highlight?: number;
|
|
||||||
filters: {
|
filters: {
|
||||||
status?: string;
|
department_id?: string;
|
||||||
academic_term_id?: string;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function studentLabel(student: CourseRegistrationStudent): string {
|
|
||||||
return `${student.user?.profile?.full_name ?? 'N/A'} - ${student.student_number}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function courseClassLabel(courseClass: CourseRegistrationCourseClass): string {
|
|
||||||
const course = courseClass.course;
|
|
||||||
|
|
||||||
return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CourseRegistrationIndex({
|
export default function CourseRegistrationIndex({
|
||||||
|
departments,
|
||||||
registrations,
|
registrations,
|
||||||
students,
|
|
||||||
academicTerms,
|
|
||||||
courseClasses,
|
|
||||||
highlight,
|
|
||||||
filters,
|
filters,
|
||||||
}: Props) {
|
}: 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[] = [
|
const filterFields: FilterField[] = [
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'department_id',
|
||||||
label: 'Status',
|
label: 'Jurusan',
|
||||||
options: RegistrationStatuses.map((status) => ({
|
options: departments.map((department) => ({
|
||||||
value: status,
|
value: String(department.id),
|
||||||
label: RegistrationStatusLabels[status],
|
label: department.name,
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'academic_term_id',
|
|
||||||
label: 'Periode Akademik',
|
|
||||||
options: academicTerms.map((term) => ({
|
|
||||||
value: String(term.id),
|
|
||||||
label: formatAcademicTermLabel(term),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -131,58 +56,19 @@ export default function CourseRegistrationIndex({
|
|||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
applyFilters,
|
applyFilters,
|
||||||
} = useServerTable({
|
} = useServerTable({
|
||||||
route: () => courseRegistrationIndex.url(),
|
route: () => index.url(),
|
||||||
pagination,
|
pagination,
|
||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleApprove(submission: CourseRegistrationSubmission) {
|
const columns = createCourseRegistrationColumns();
|
||||||
router.patch(approve(submission.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = createCourseRegistrationColumns({
|
|
||||||
handleApprove,
|
|
||||||
handleRejectClick: (submission) => setRejecting(submission),
|
|
||||||
canApprove,
|
|
||||||
canReject,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Registrasi KRS" />
|
<Head title="Registrasi KRS" />
|
||||||
|
|
||||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader title="Registrasi KRS" />
|
||||||
title="Registrasi KRS"
|
|
||||||
description={
|
|
||||||
highlight && (
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Menampilkan registrasi dari notifikasi.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
canCreate && (
|
|
||||||
<Button asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCreateOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Tambah
|
|
||||||
</button>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CreateForm
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
students={students}
|
|
||||||
academicTerms={academicTerms}
|
|
||||||
courseClasses={courseClasses}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -201,257 +87,7 @@ export default function CourseRegistrationIndex({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RejectForm
|
|
||||||
open={rejecting !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setRejecting(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
submission={rejecting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RejectForm({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
submission,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
submission: CourseRegistrationSubmission | null;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FormDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title="Tolak Registrasi KRS"
|
|
||||||
action={submission ? reject(submission.id) : ''}
|
|
||||||
resetOnSuccess
|
|
||||||
onSuccess={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
{({ errors }) => (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
KRS milik{' '}
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{submission?.student?.user?.profile?.full_name ??
|
|
||||||
'mahasiswa ini'}
|
|
||||||
</span>{' '}
|
|
||||||
akan ditolak. Mahasiswa perlu memperbaiki dan mengajukan
|
|
||||||
ulang.
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="reason">
|
|
||||||
Alasan Penolakan{' '}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="reason"
|
|
||||||
name="reason"
|
|
||||||
placeholder="Jelaskan alasan penolakan"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.reason} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FormDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegistrationFields({
|
|
||||||
errors,
|
|
||||||
students,
|
|
||||||
academicTerms,
|
|
||||||
courseClasses,
|
|
||||||
}: {
|
|
||||||
errors: Record<string, string>;
|
|
||||||
students: CourseRegistrationStudent[];
|
|
||||||
academicTerms: AcademicTermOption[];
|
|
||||||
courseClasses: CourseRegistrationCourseClass[];
|
|
||||||
}) {
|
|
||||||
const activeTermId = academicTerms.find((term) => term.is_active)?.id;
|
|
||||||
|
|
||||||
const [student, setStudent] = useState<CourseRegistrationStudent | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [academicTermId, setAcademicTermId] = useState(() =>
|
|
||||||
activeTermId ? String(activeTermId) : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
const availableCourseClasses = courseClasses.filter(
|
|
||||||
(courseClass) =>
|
|
||||||
student &&
|
|
||||||
academicTermId &&
|
|
||||||
courseClass.academic_term_id === Number(academicTermId) &&
|
|
||||||
courseClass.course?.semester_number === student.current_semester &&
|
|
||||||
courseClass.course?.department_id === student.department?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectionKey = `${student?.id ?? ''}-${academicTermId}`;
|
|
||||||
const [selected, setSelected] = useState<number[]>([]);
|
|
||||||
const [selectedForKey, setSelectedForKey] = useState(selectionKey);
|
|
||||||
|
|
||||||
if (selectionKey !== selectedForKey) {
|
|
||||||
setSelectedForKey(selectionKey);
|
|
||||||
setSelected([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(courseClassId: number, checked: boolean) {
|
|
||||||
setSelected((prev) =>
|
|
||||||
checked
|
|
||||||
? [...prev, courseClassId]
|
|
||||||
: prev.filter((id) => id !== courseClassId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
Mahasiswa <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="student_id"
|
|
||||||
value={student?.id ?? ''}
|
|
||||||
/>
|
|
||||||
<Combobox
|
|
||||||
items={students}
|
|
||||||
value={student}
|
|
||||||
onValueChange={setStudent}
|
|
||||||
itemToStringLabel={studentLabel}
|
|
||||||
isItemEqualToValue={(a, b) => a.id === b.id}
|
|
||||||
>
|
|
||||||
<ComboboxInput
|
|
||||||
placeholder="Pilih mahasiswa"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ComboboxContent>
|
|
||||||
<ComboboxEmpty>
|
|
||||||
Mahasiswa tidak ditemukan.
|
|
||||||
</ComboboxEmpty>
|
|
||||||
<ComboboxList>
|
|
||||||
{(option: CourseRegistrationStudent) => (
|
|
||||||
<ComboboxItem key={option.id} value={option}>
|
|
||||||
{studentLabel(option)}
|
|
||||||
</ComboboxItem>
|
|
||||||
)}
|
|
||||||
</ComboboxList>
|
|
||||||
</ComboboxContent>
|
|
||||||
</Combobox>
|
|
||||||
<InputError message={errors.student_id} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
Periode Akademik <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="academic_term_id"
|
|
||||||
value={academicTermId}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={academicTermId}
|
|
||||||
onValueChange={setAcademicTermId}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Pilih periode akademik" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{academicTerms.map((term) => (
|
|
||||||
<SelectItem key={term.id} value={String(term.id)}>
|
|
||||||
{formatAcademicTermLabel(term)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<InputError message={errors.academic_term_id} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
Kelas Mata Kuliah{' '}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
{!student || !academicTermId ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Pilih mahasiswa dan periode terlebih dahulu.
|
|
||||||
</p>
|
|
||||||
) : availableCourseClasses.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Tidak ada kelas mata kuliah yang tersedia untuk semester
|
|
||||||
mahasiswa ini.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2">
|
|
||||||
{availableCourseClasses.map((courseClass) => (
|
|
||||||
<label
|
|
||||||
key={courseClass.id}
|
|
||||||
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.includes(courseClass.id)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggle(courseClass.id, checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">
|
|
||||||
{courseClassLabel(courseClass)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selected.map((courseClassId) => (
|
|
||||||
<input
|
|
||||||
key={courseClassId}
|
|
||||||
type="hidden"
|
|
||||||
name="course_class_ids[]"
|
|
||||||
value={courseClassId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<InputError message={errors.course_class_ids} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateForm({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
students,
|
|
||||||
academicTerms,
|
|
||||||
courseClasses,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
students: CourseRegistrationStudent[];
|
|
||||||
academicTerms: AcademicTermOption[];
|
|
||||||
courseClasses: CourseRegistrationCourseClass[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FormDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title="Tambah Registrasi KRS"
|
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
|
||||||
onSuccess={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
{({ errors }) => (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<RegistrationFields
|
|
||||||
errors={errors}
|
|
||||||
students={students}
|
|
||||||
academicTerms={academicTerms}
|
|
||||||
courseClasses={courseClasses}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FormDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,150 +1,162 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Form, Head, Link, router } from '@inertiajs/react';
|
||||||
import { format } from 'date-fns';
|
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 { PageHeader } from '@/components/page-header';
|
||||||
|
import { SignaturePad } from '@/components/signature-pad';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { index as courseRegistrationIndex } from '@/routes/admin/manage/course-registrations';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
import { Label } from '@/components/ui/label';
|
||||||
import type { CourseRegistrationSubmission } from '@/types/course-registration';
|
import {
|
||||||
import { RegistrationStatusLabels } from '@/types/course-registration';
|
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 = {
|
const RegistrationStatusVariants: Record<
|
||||||
submission: CourseRegistrationSubmission;
|
CourseRegistrationSubmission['status'],
|
||||||
|
'default' | 'secondary' | 'destructive' | 'outline'
|
||||||
|
> = {
|
||||||
|
submitted: 'outline',
|
||||||
|
approved: 'default',
|
||||||
|
rejected: 'destructive',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CourseRegistrationShow({ submission }: Props) {
|
function isSemesterOpenForStudent(
|
||||||
const student = submission.student;
|
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 (
|
return (
|
||||||
<>
|
<div className="h-28 w-48 overflow-hidden rounded border bg-white">
|
||||||
<Head title="Detail Registrasi KRS" />
|
|
||||||
|
|
||||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Detail Registrasi KRS"
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href={courseRegistrationIndex.url()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Kembali
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<CardTitle>
|
|
||||||
{student?.user?.profile?.full_name ?? 'N/A'}
|
|
||||||
</CardTitle>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{student?.student_number} ·{' '}
|
|
||||||
{student?.department?.name ?? '-'} ·
|
|
||||||
Semester {student?.current_semester ?? '-'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Periode:{' '}
|
|
||||||
{submission.academic_term
|
|
||||||
? formatAcademicTermLabel(
|
|
||||||
submission.academic_term,
|
|
||||||
)
|
|
||||||
: '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
submission.status === 'approved'
|
|
||||||
? 'default'
|
|
||||||
: submission.status === 'rejected'
|
|
||||||
? 'destructive'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{RegistrationStatusLabels[submission.status]}
|
|
||||||
</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
Tanda Tangan
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{submission.signature_url ? (
|
|
||||||
<div className="w-fit rounded-md border bg-white p-2">
|
|
||||||
<img
|
<img
|
||||||
src={submission.signature_url}
|
src={signatureUrl}
|
||||||
alt="Tanda tangan"
|
alt={alt}
|
||||||
className="h-24"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSign) {
|
||||||
|
return <div className="h-28 w-48" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
router.patch(
|
||||||
|
submitUrl,
|
||||||
|
{ signature: file },
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => setProcessing(false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<SignaturePad name="_signature" compact onCapture={setFile} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!file || processing}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{processing ? 'Menyimpan...' : 'Simpan TTD'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,7 +164,7 @@ function LogBubble({
|
|||||||
log,
|
log,
|
||||||
isResubmission,
|
isResubmission,
|
||||||
}: {
|
}: {
|
||||||
log: CourseRegistrationSubmission['logs'][number];
|
log: CourseRegistrationSubmissionLog;
|
||||||
isResubmission: boolean;
|
isResubmission: boolean;
|
||||||
}) {
|
}) {
|
||||||
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
|
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
|
||||||
@ -182,3 +194,563 @@ function LogBubble({
|
|||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table';
|
|||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { ActiveStatusSwitch } from '@/components/active-status-switch';
|
import { ActiveStatusSwitch } from '@/components/active-status-switch';
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { SemesterLabels } from '@/types/academic-term';
|
import { SemesterLabels } from '@/types/academic-term';
|
||||||
import type { AcademicTerm } from '@/types/academic-term';
|
import type { AcademicTerm } from '@/types/academic-term';
|
||||||
import type { Semester } from '@/types/academic-term';
|
import type { Semester } from '@/types/academic-term';
|
||||||
@ -31,11 +32,11 @@ export function createAcademicTermColumns(
|
|||||||
|
|
||||||
const columns: ColumnDef<AcademicTerm>[] = [
|
const columns: ColumnDef<AcademicTerm>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'academic_year',
|
||||||
header: () => <span>Nama</span>,
|
header: () => <span>Tahun Ajaran</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{row.getValue('name') as string}
|
{row.getValue('academic_year') as string}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -66,6 +67,33 @@ export function createAcademicTermColumns(
|
|||||||
</span>
|
</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',
|
accessorKey: 'is_active',
|
||||||
header: () => <span className="block text-center">Status</span>,
|
header: () => <span className="block text-center">Status</span>,
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export default function AcademicTermIndex({
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={academicTerms.data}
|
data={academicTerms.data}
|
||||||
searchKey="name"
|
searchKey="academic_year"
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPerPageChange={handlePerPageChange}
|
onPerPageChange={handlePerPageChange}
|
||||||
@ -206,7 +206,7 @@ export default function AcademicTermIndex({
|
|||||||
}}
|
}}
|
||||||
title="Hapus Periode Akademik"
|
title="Hapus Periode Akademik"
|
||||||
description={(academicTerm) =>
|
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}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
@ -241,15 +241,16 @@ function CreateForm({
|
|||||||
{({ errors }) => (
|
{({ errors }) => (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="academic_year">
|
||||||
Nama <span className="text-destructive">*</span>
|
Tahun Ajaran{' '}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="academic_year"
|
||||||
name="name"
|
name="academic_year"
|
||||||
placeholder="Masukkan nama periode akademik"
|
placeholder="Masukkan tahun ajaran, contoh: 2025/2026"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
<InputError message={errors.academic_year} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
@ -329,6 +330,7 @@ function CreateForm({
|
|||||||
<Checkbox id="is_active" name="is_active" value="1" />
|
<Checkbox id="is_active" name="is_active" value="1" />
|
||||||
<Label htmlFor="is_active">Aktif</Label>
|
<Label htmlFor="is_active">Aktif</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<OpenSemestersField idPrefix="create" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
@ -368,16 +370,17 @@ function EditForm({
|
|||||||
editing && (
|
editing && (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-name">
|
<Label htmlFor="edit-academic_year">
|
||||||
Nama <span className="text-destructive">*</span>
|
Tahun Ajaran{' '}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-name"
|
id="edit-academic_year"
|
||||||
name="name"
|
name="academic_year"
|
||||||
placeholder="Masukkan nama periode akademik"
|
placeholder="Masukkan tahun ajaran, contoh: 2025/2026"
|
||||||
defaultValue={editing.name}
|
defaultValue={editing.academic_year}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
<InputError message={errors.academic_year} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
@ -467,9 +470,49 @@ function EditForm({
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="edit-is_active">Aktif</Label>
|
<Label htmlFor="edit-is_active">Aktif</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<OpenSemestersField
|
||||||
|
idPrefix="edit"
|
||||||
|
defaultValue={editing.open_semesters ?? []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</FormDialog>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import type { Course } from '@/types/course';
|
import type { Course } from '@/types/course';
|
||||||
|
|
||||||
export type { Course } from '@/types/course';
|
export type { Course } from '@/types/course';
|
||||||
@ -33,11 +32,6 @@ export function createCourseColumns(
|
|||||||
header: () => <span>Nama Mata Kuliah</span>,
|
header: () => <span>Nama Mata Kuliah</span>,
|
||||||
cell: ({ row }) => <span>{row.getValue('name') as string}</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',
|
accessorKey: 'credits',
|
||||||
header: () => <span className="block text-center">SKS</span>,
|
header: () => <span className="block text-center">SKS</span>,
|
||||||
@ -51,28 +45,6 @@ export function createCourseColumns(
|
|||||||
</div>
|
</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) {
|
if (canUpdate || canDelete) {
|
||||||
|
|||||||
@ -191,6 +191,9 @@ export default function CourseIndex({
|
|||||||
onPerPageChange={handlePerPageChange}
|
onPerPageChange={handlePerPageChange}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
|
groupBy={(course) =>
|
||||||
|
`${course.department?.name ?? 'Tanpa Jurusan'} - Semester ${course.semester_number ?? 'Tidak ditentukan'}`
|
||||||
|
}
|
||||||
toolbar={
|
toolbar={
|
||||||
<FilterDialog
|
<FilterDialog
|
||||||
fields={filterFields}
|
fields={filterFields}
|
||||||
|
|||||||
@ -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) {
|
if (canUpdate || canDelete) {
|
||||||
|
|||||||
@ -1,13 +1,23 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DatePicker } from '@/components/date-picker';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FormDialog } from '@/components/form-dialog';
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
} from '@/components/ui/combobox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
@ -26,7 +36,7 @@ import {
|
|||||||
update,
|
update,
|
||||||
} from '@/routes/admin/master/departments';
|
} from '@/routes/admin/master/departments';
|
||||||
import { DegreeLevels } from '@/types/department';
|
import { DegreeLevels } from '@/types/department';
|
||||||
import type { Department } from '@/types/department';
|
import type { Department, DepartmentLecturer } from '@/types/department';
|
||||||
import { createDepartmentColumns } from './columns';
|
import { createDepartmentColumns } from './columns';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -37,10 +47,19 @@ type Props = {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
lecturers: DepartmentLecturer[];
|
||||||
highlight?: number;
|
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 [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Department | null>(null);
|
const [editing, setEditing] = useState<Department | null>(null);
|
||||||
const [deleting, setDeleting] = 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
|
<EditForm
|
||||||
key={editing?.id}
|
key={editing?.id}
|
||||||
@ -138,6 +161,7 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
|
lecturers={lecturers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTable
|
<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({
|
function CreateForm({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
lecturers,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
lecturers: DepartmentLecturer[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -222,6 +347,7 @@ function CreateForm({
|
|||||||
</Select>
|
</Select>
|
||||||
<InputError message={errors.degree_level} />
|
<InputError message={errors.degree_level} />
|
||||||
</div>
|
</div>
|
||||||
|
<KaprodiFields errors={errors} lecturers={lecturers} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
@ -232,10 +358,12 @@ function EditForm({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
editing,
|
editing,
|
||||||
|
lecturers,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
editing: Department | null;
|
editing: Department | null;
|
||||||
|
lecturers: DepartmentLecturer[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -297,6 +425,11 @@ function EditForm({
|
|||||||
</Select>
|
</Select>
|
||||||
<InputError message={errors.degree_level} />
|
<InputError message={errors.degree_level} />
|
||||||
</div>
|
</div>
|
||||||
|
<KaprodiFields
|
||||||
|
errors={errors}
|
||||||
|
lecturers={lecturers}
|
||||||
|
editing={editing}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export function createAcademicAdvisingLogColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'lecturer.user.profile.full_name',
|
accessorKey: 'lecturer.user.profile.full_name',
|
||||||
header: () => <span>Dosen Wali</span>,
|
header: () => <span>Pembimbing Akademik</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const lecturer = row.original.lecturer;
|
const lecturer = row.original.lecturer;
|
||||||
|
|
||||||
|
|||||||
@ -258,7 +258,7 @@ function ViewDetailDialog({
|
|||||||
|
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
<Label className="text-muted-foreground">
|
<Label className="text-muted-foreground">
|
||||||
Dosen Wali
|
Pembimbing Akademik
|
||||||
</Label>
|
</Label>
|
||||||
<p>
|
<p>
|
||||||
{log.lecturer?.user?.profile?.full_name ??
|
{log.lecturer?.user?.profile?.full_name ??
|
||||||
@ -338,7 +338,8 @@ function AdvisingLogFields({
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Dosen Wali <span className="text-destructive">*</span>
|
Pembimbing Akademik{' '}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
|
|||||||
@ -303,7 +303,7 @@ export default function Profile({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Dosen Wali</Label>
|
<Label>Pembimbing Akademik</Label>
|
||||||
<Input
|
<Input
|
||||||
disabled
|
disabled
|
||||||
value={
|
value={
|
||||||
|
|||||||
@ -35,6 +35,7 @@ type Lecturer = {
|
|||||||
id: number;
|
id: number;
|
||||||
lecturer_number: string;
|
lecturer_number: string;
|
||||||
user: { profile: { full_name: string } | null } | null;
|
user: { profile: { full_name: string } | null } | null;
|
||||||
|
departments: { id: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -45,8 +46,15 @@ type Props = {
|
|||||||
export default function StudentCreate({ departments, lecturers }: Props) {
|
export default function StudentCreate({ departments, lecturers }: Props) {
|
||||||
const [gender, setGender] = useState('');
|
const [gender, setGender] = useState('');
|
||||||
const [birthDate, setBirthDate] = useState<Date | undefined>();
|
const [birthDate, setBirthDate] = useState<Date | undefined>();
|
||||||
|
const [departmentId, setDepartmentId] = useState('');
|
||||||
const [advisor, setAdvisor] = useState<Lecturer | null>(null);
|
const [advisor, setAdvisor] = useState<Lecturer | null>(null);
|
||||||
|
|
||||||
|
const availableAdvisors = departmentId
|
||||||
|
? lecturers.filter((l) =>
|
||||||
|
l.departments.some((d) => String(d.id) === departmentId),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Tambah Mahasiswa" />
|
<Head title="Tambah Mahasiswa" />
|
||||||
@ -279,8 +287,15 @@ export default function StudentCreate({ departments, lecturers }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="department_id"
|
name="department_id"
|
||||||
|
value={departmentId}
|
||||||
/>
|
/>
|
||||||
<Select name="department_id">
|
<Select
|
||||||
|
value={departmentId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setDepartmentId(value);
|
||||||
|
setAdvisor(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Pilih jurusan" />
|
<SelectValue placeholder="Pilih jurusan" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -343,7 +358,7 @@ export default function StudentCreate({ departments, lecturers }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Dosen Wali{' '}
|
Pembimbing Akademik{' '}
|
||||||
<span className="text-red-500">
|
<span className="text-red-500">
|
||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
@ -354,8 +369,9 @@ export default function StudentCreate({ departments, lecturers }: Props) {
|
|||||||
value={advisor?.id ?? ''}
|
value={advisor?.id ?? ''}
|
||||||
/>
|
/>
|
||||||
<Combobox
|
<Combobox
|
||||||
items={lecturers}
|
items={availableAdvisors}
|
||||||
value={advisor}
|
value={advisor}
|
||||||
|
disabled={!departmentId}
|
||||||
onValueChange={setAdvisor}
|
onValueChange={setAdvisor}
|
||||||
itemToStringLabel={(lect) =>
|
itemToStringLabel={(lect) =>
|
||||||
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
||||||
@ -365,12 +381,18 @@ export default function StudentCreate({ departments, lecturers }: Props) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
placeholder="Pilih dosen wali"
|
disabled={!departmentId}
|
||||||
|
placeholder={
|
||||||
|
departmentId
|
||||||
|
? 'Pilih pembimbing akademik'
|
||||||
|
: 'Pilih jurusan terlebih dahulu'
|
||||||
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxEmpty>
|
<ComboboxEmpty>
|
||||||
Dosen tidak ditemukan.
|
Dosen tidak
|
||||||
|
ditemukan.
|
||||||
</ComboboxEmpty>
|
</ComboboxEmpty>
|
||||||
<ComboboxList>
|
<ComboboxList>
|
||||||
{(lect: Lecturer) => (
|
{(lect: Lecturer) => (
|
||||||
|
|||||||
@ -35,6 +35,7 @@ type Lecturer = {
|
|||||||
id: number;
|
id: number;
|
||||||
lecturer_number: string;
|
lecturer_number: string;
|
||||||
user: { profile: { full_name: string } | null } | null;
|
user: { profile: { full_name: string } | null } | null;
|
||||||
|
departments: { id: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
@ -71,12 +72,23 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
? new Date(user.profile.birth_date)
|
? new Date(user.profile.birth_date)
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
const [departmentId, setDepartmentId] = useState(
|
||||||
|
user.student?.department_id
|
||||||
|
? String(user.student.department_id)
|
||||||
|
: '',
|
||||||
|
);
|
||||||
const [advisor, setAdvisor] = useState<Lecturer | null>(
|
const [advisor, setAdvisor] = useState<Lecturer | null>(
|
||||||
lecturers.find(
|
lecturers.find(
|
||||||
(lect) => lect.id === user.student?.academic_advisor_id,
|
(lect) => lect.id === user.student?.academic_advisor_id,
|
||||||
) ?? null,
|
) ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const availableAdvisors = departmentId
|
||||||
|
? lecturers.filter((l) =>
|
||||||
|
l.departments.some((d) => String(d.id) === departmentId),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Edit Mahasiswa" />
|
<Head title="Edit Mahasiswa" />
|
||||||
@ -318,16 +330,17 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<input
|
||||||
|
type="hidden"
|
||||||
name="department_id"
|
name="department_id"
|
||||||
defaultValue={
|
value={departmentId}
|
||||||
user.student?.department_id
|
/>
|
||||||
? String(
|
<Select
|
||||||
user.student
|
value={departmentId}
|
||||||
.department_id,
|
onValueChange={(value) => {
|
||||||
)
|
setDepartmentId(value);
|
||||||
: undefined
|
setAdvisor(null);
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Pilih jurusan" />
|
<SelectValue placeholder="Pilih jurusan" />
|
||||||
@ -396,7 +409,7 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Dosen Wali{' '}
|
Pembimbing Akademik{' '}
|
||||||
<span className="text-red-500">
|
<span className="text-red-500">
|
||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
@ -407,8 +420,9 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
value={advisor?.id ?? ''}
|
value={advisor?.id ?? ''}
|
||||||
/>
|
/>
|
||||||
<Combobox
|
<Combobox
|
||||||
items={lecturers}
|
items={availableAdvisors}
|
||||||
value={advisor}
|
value={advisor}
|
||||||
|
disabled={!departmentId}
|
||||||
onValueChange={setAdvisor}
|
onValueChange={setAdvisor}
|
||||||
itemToStringLabel={(lect) =>
|
itemToStringLabel={(lect) =>
|
||||||
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
|
||||||
@ -418,12 +432,18 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
placeholder="Pilih dosen wali"
|
disabled={!departmentId}
|
||||||
|
placeholder={
|
||||||
|
departmentId
|
||||||
|
? 'Pilih pembimbing akademik'
|
||||||
|
: 'Pilih jurusan terlebih dahulu'
|
||||||
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxEmpty>
|
<ComboboxEmpty>
|
||||||
Dosen tidak ditemukan.
|
Dosen tidak
|
||||||
|
ditemukan.
|
||||||
</ComboboxEmpty>
|
</ComboboxEmpty>
|
||||||
<ComboboxList>
|
<ComboboxList>
|
||||||
{(lect: Lecturer) => (
|
{(lect: Lecturer) => (
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { RowActions } from '@/components/row-actions';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { show } from '@/routes/student/course-registrations';
|
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
||||||
import type { RegistrationStatus } from '@/types/course-registration';
|
|
||||||
import { RegistrationStatusLabels } from '@/types/course-registration';
|
|
||||||
|
|
||||||
export type SubmissionSummary = {
|
|
||||||
id: number;
|
|
||||||
status: RegistrationStatus;
|
|
||||||
signed_at: string;
|
|
||||||
academic_term: { id: number; name: string; semester: string } | null;
|
|
||||||
course_registrations_count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const submissionColumns: ColumnDef<SubmissionSummary>[] = [
|
|
||||||
{
|
|
||||||
id: 'academic_term',
|
|
||||||
header: () => <span>Periode Akademik</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const term = row.original.academic_term;
|
|
||||||
|
|
||||||
return term ? formatAcademicTermLabel(term) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'count',
|
|
||||||
header: () => <span className="block text-center">Jumlah</span>,
|
|
||||||
meta: {
|
|
||||||
className: 'w-[100px] text-center',
|
|
||||||
headerClassName: 'w-[100px] text-center',
|
|
||||||
},
|
|
||||||
cell: ({ row }) => row.original.course_registrations_count,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: () => <span className="block text-center">Status</span>,
|
|
||||||
meta: {
|
|
||||||
className: 'w-[140px] text-center',
|
|
||||||
headerClassName: 'w-[140px] text-center',
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const status = row.original.status;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
status === 'approved'
|
|
||||||
? 'default'
|
|
||||||
: status === 'rejected'
|
|
||||||
? 'destructive'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{RegistrationStatusLabels[status]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'signed_at',
|
|
||||||
header: () => <span>Tanggal</span>,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
format(new Date(row.original.signed_at), 'd MMM yyyy, HH:mm'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <span className="block text-center">Aksi</span>,
|
|
||||||
meta: {
|
|
||||||
className: 'w-[80px] text-center',
|
|
||||||
headerClassName: 'w-[80px] text-center',
|
|
||||||
},
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<RowActions
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: 'Lihat Detail',
|
|
||||||
icon: <Eye className="h-4 w-4" />,
|
|
||||||
href: show.url(row.original.id),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
import { Form, Head, Link } from '@inertiajs/react';
|
|
||||||
import { ArrowLeft, XCircle } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import InputError from '@/components/input-error';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { SignaturePad } from '@/components/signature-pad';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { index, store } from '@/routes/student/course-registrations';
|
|
||||||
import type { AcademicTerm } from '@/types/academic-term';
|
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
||||||
|
|
||||||
type Lecturer = {
|
|
||||||
user: { profile: { full_name: string } | null } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Course = {
|
|
||||||
id: number;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
credits: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CourseClass = {
|
|
||||||
id: number;
|
|
||||||
lecturer: Lecturer | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Student = {
|
|
||||||
student_number: string;
|
|
||||||
current_semester: number;
|
|
||||||
department: { name: string } | null;
|
|
||||||
user: { profile: { full_name: string } | null } | null;
|
|
||||||
academic_advisor: {
|
|
||||||
user: { profile: { full_name: string } | null } | null;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AvailableCourse = {
|
|
||||||
course: Course;
|
|
||||||
course_class: CourseClass | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Registration = {
|
|
||||||
id: number;
|
|
||||||
course_class: { id: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
type Submission = {
|
|
||||||
status: 'submitted' | 'approved' | 'rejected';
|
|
||||||
rejection_reason: string | null;
|
|
||||||
course_registrations: Registration[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
student: Student;
|
|
||||||
academicTerm: AcademicTerm;
|
|
||||||
submission: Submission | null;
|
|
||||||
availableCourses: AvailableCourse[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CourseRegistrationCreate({
|
|
||||||
student,
|
|
||||||
academicTerm,
|
|
||||||
submission,
|
|
||||||
availableCourses,
|
|
||||||
}: Props) {
|
|
||||||
const [selected, setSelected] = useState<number[]>(() => {
|
|
||||||
if (submission && submission.course_registrations.length > 0) {
|
|
||||||
return submission.course_registrations.map(
|
|
||||||
(registration) => registration.course_class.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return availableCourses
|
|
||||||
.filter((item) => item.course_class)
|
|
||||||
.map((item) => item.course_class!.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggle(courseClassId: number, checked: boolean) {
|
|
||||||
setSelected((prev) =>
|
|
||||||
checked
|
|
||||||
? [...prev, courseClassId]
|
|
||||||
: prev.filter((id) => id !== courseClassId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCredits = availableCourses
|
|
||||||
.filter(
|
|
||||||
(item) =>
|
|
||||||
item.course_class && selected.includes(item.course_class.id),
|
|
||||||
)
|
|
||||||
.reduce((sum, item) => sum + item.course.credits, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title="Isi KRS" />
|
|
||||||
|
|
||||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Isi Kartu Rencana Studi"
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href={index.url()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Kembali
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
{student.user?.profile?.full_name ?? '-'}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{student.student_number} ·{' '}
|
|
||||||
{student.department?.name ?? '-'} · Semester{' '}
|
|
||||||
{student.current_semester}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-1 text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Periode Akademik:{' '}
|
|
||||||
{formatAcademicTermLabel(academicTerm)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Dosen Pembimbing Akademik:{' '}
|
|
||||||
{student.academic_advisor?.user?.profile
|
|
||||||
?.full_name ?? '-'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{submission?.status === 'rejected' && (
|
|
||||||
<Card className="border-destructive/50">
|
|
||||||
<CardContent className="pt-6 text-sm">
|
|
||||||
<p className="flex items-center gap-2 font-medium text-destructive">
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
KRS Anda ditolak
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
{submission.rejection_reason ??
|
|
||||||
'Tidak ada alasan yang diberikan.'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
Silakan perbaiki pilihan mata kuliah Anda dan
|
|
||||||
ajukan kembali di bawah ini.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form action={store()}>
|
|
||||||
{({ errors, processing }) => (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
Pilih Mata Kuliah Semester{' '}
|
|
||||||
{student.current_semester}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Total {totalCredits} SKS dipilih
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-3">
|
|
||||||
{availableCourses.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Belum ada mata kuliah/kelas untuk
|
|
||||||
semester ini pada periode aktif.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{availableCourses.map(
|
|
||||||
({ course, course_class }) => (
|
|
||||||
<label
|
|
||||||
key={course.id}
|
|
||||||
className="flex items-start gap-3 rounded-md border p-3 has-disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
className="mt-0.5"
|
|
||||||
checked={
|
|
||||||
course_class
|
|
||||||
? selected.includes(
|
|
||||||
course_class.id,
|
|
||||||
)
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
disabled={!course_class}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
course_class &&
|
|
||||||
toggle(
|
|
||||||
course_class.id,
|
|
||||||
checked === true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">
|
|
||||||
{course.code} -{' '}
|
|
||||||
{course.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{course.credits} SKS
|
|
||||||
·{' '}
|
|
||||||
{course_class
|
|
||||||
? (course_class.lecturer
|
|
||||||
?.user?.profile
|
|
||||||
?.full_name ??
|
|
||||||
'Dosen belum ditentukan')
|
|
||||||
: 'Kelas belum dibuka'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
{selected.map((courseClassId) => (
|
|
||||||
<input
|
|
||||||
key={courseClassId}
|
|
||||||
type="hidden"
|
|
||||||
name="course_class_ids[]"
|
|
||||||
value={courseClassId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<InputError message={errors.course_class_ids} />
|
|
||||||
</CardContent>
|
|
||||||
<CardContent className="border-t pt-6">
|
|
||||||
<SignaturePad
|
|
||||||
name="signature"
|
|
||||||
error={errors.signature}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="justify-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
processing || selected.length === 0
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{submission?.status === 'rejected'
|
|
||||||
? 'Ajukan Ulang KRS'
|
|
||||||
: 'Tanda Tangan KRS'}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
|
||||||
import { XCircle } from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { formatRupiah } from '@/lib/currency';
|
|
||||||
import { create } from '@/routes/student/course-registrations';
|
|
||||||
import type { AcademicTerm } from '@/types/academic-term';
|
|
||||||
import type { RegistrationStatus } from '@/types/course-registration';
|
|
||||||
import type { SubmissionSummary } from './columns';
|
|
||||||
import { submissionColumns } from './columns';
|
|
||||||
|
|
||||||
type PaymentStatus = {
|
|
||||||
has_invoice: boolean;
|
|
||||||
is_paid: boolean;
|
|
||||||
amount_due: number | null;
|
|
||||||
paid_total: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActiveSubmission = {
|
|
||||||
status: RegistrationStatus;
|
|
||||||
rejection_reason: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
academicTerm: AcademicTerm | null;
|
|
||||||
payment: PaymentStatus | null;
|
|
||||||
canSubmit: boolean;
|
|
||||||
activeSubmission: ActiveSubmission | null;
|
|
||||||
submissions: SubmissionSummary[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CourseRegistrationIndex({
|
|
||||||
academicTerm,
|
|
||||||
payment,
|
|
||||||
canSubmit,
|
|
||||||
activeSubmission,
|
|
||||||
submissions,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title="KRS" />
|
|
||||||
|
|
||||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Kartu Rencana Studi"
|
|
||||||
description={
|
|
||||||
academicTerm && (
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Periode aktif: {academicTerm.name} (
|
|
||||||
{academicTerm.semester === 'odd'
|
|
||||||
? 'Ganjil'
|
|
||||||
: 'Genap'}
|
|
||||||
)
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!academicTerm && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
|
||||||
Belum ada periode akademik yang aktif. Silakan
|
|
||||||
hubungi admin.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{academicTerm && payment && !payment.is_paid && (
|
|
||||||
<Card className="border-destructive/50">
|
|
||||||
<CardContent className="pt-6 text-sm">
|
|
||||||
<p className="font-medium text-destructive">
|
|
||||||
Anda belum bisa mengisi KRS periode ini.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
{payment.has_invoice
|
|
||||||
? `Tagihan periode ini belum lunas (terbayar ${formatRupiah(payment.paid_total ?? 0)} dari ${formatRupiah(payment.amount_due ?? 0)}).`
|
|
||||||
: 'Tagihan untuk periode ini belum diterbitkan. Silakan hubungi staf keuangan.'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{academicTerm && payment?.is_paid && canSubmit && (
|
|
||||||
<Card
|
|
||||||
className={
|
|
||||||
activeSubmission?.status === 'rejected'
|
|
||||||
? 'border-destructive/50'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CardContent className="flex flex-col gap-3 pt-6 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
{activeSubmission?.status === 'rejected' ? (
|
|
||||||
<>
|
|
||||||
<p className="flex items-center gap-2 text-sm font-medium text-destructive">
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
KRS Anda untuk periode ini ditolak
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{activeSubmission.rejection_reason ??
|
|
||||||
'Tidak ada alasan yang diberikan.'}{' '}
|
|
||||||
Silakan perbaiki dan ajukan kembali.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Anda belum mengisi KRS untuk periode
|
|
||||||
aktif ini.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={create.url()}>
|
|
||||||
{activeSubmission?.status === 'rejected'
|
|
||||||
? 'Ajukan Ulang KRS'
|
|
||||||
: 'Isi KRS'}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataTable columns={submissionColumns} data={submissions} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { index } from '@/routes/student/course-registrations';
|
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
||||||
import type { CourseRegistrationSubmission } from '@/types/course-registration';
|
|
||||||
import { RegistrationStatusLabels } from '@/types/course-registration';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
submission: CourseRegistrationSubmission;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CourseRegistrationShow({ submission }: Props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title="Detail KRS" />
|
|
||||||
|
|
||||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Detail Kartu Rencana Studi"
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href={index.url()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Kembali
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<CardTitle>
|
|
||||||
{submission.academic_term
|
|
||||||
? formatAcademicTermLabel(
|
|
||||||
submission.academic_term,
|
|
||||||
)
|
|
||||||
: '-'}
|
|
||||||
</CardTitle>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Ditandatangani pada{' '}
|
|
||||||
{format(
|
|
||||||
new Date(submission.signed_at),
|
|
||||||
'd MMM yyyy, HH:mm',
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
submission.status === 'approved'
|
|
||||||
? 'default'
|
|
||||||
: submission.status === 'rejected'
|
|
||||||
? 'destructive'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{RegistrationStatusLabels[submission.status]}
|
|
||||||
</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
Tanda Tangan
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{submission.signature_url ? (
|
|
||||||
<div className="w-fit rounded-md border bg-white p-2">
|
|
||||||
<img
|
|
||||||
src={submission.signature_url}
|
|
||||||
alt="Tanda tangan"
|
|
||||||
className="h-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Tidak ada tanda tangan.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
Mata Kuliah (
|
|
||||||
{submission.course_registrations.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-2">
|
|
||||||
{submission.course_registrations.map((registration) => (
|
|
||||||
<div
|
|
||||||
key={registration.id}
|
|
||||||
className="rounded-md border p-3"
|
|
||||||
>
|
|
||||||
<p className="font-medium">
|
|
||||||
{registration.course_class?.course?.code ??
|
|
||||||
'-'}{' '}
|
|
||||||
-{' '}
|
|
||||||
{registration.course_class?.course?.name ??
|
|
||||||
'N/A'}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{registration.course_class?.course
|
|
||||||
?.credits ?? '-'}{' '}
|
|
||||||
SKS ·{' '}
|
|
||||||
{registration.course_class?.lecturer?.user
|
|
||||||
?.profile?.full_name ??
|
|
||||||
'Dosen belum ditentukan'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Riwayat</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-2">
|
|
||||||
{submission.logs.map((log, index) => (
|
|
||||||
<LogBubble
|
|
||||||
key={log.id}
|
|
||||||
log={log}
|
|
||||||
isResubmission={
|
|
||||||
log.status === 'submitted' &&
|
|
||||||
submission.logs
|
|
||||||
.slice(0, index)
|
|
||||||
.some((l) => l.status === 'submitted')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogBubble({
|
|
||||||
log,
|
|
||||||
isResubmission,
|
|
||||||
}: {
|
|
||||||
log: CourseRegistrationSubmission['logs'][number];
|
|
||||||
isResubmission: boolean;
|
|
||||||
}) {
|
|
||||||
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
|
|
||||||
|
|
||||||
const message =
|
|
||||||
log.status === 'approved'
|
|
||||||
? `${actorName} menyetujui KRS ini.`
|
|
||||||
: log.status === 'rejected'
|
|
||||||
? `${actorName} menolak KRS ini dengan alasan: "${log.reason}"`
|
|
||||||
: isResubmission
|
|
||||||
? `${actorName} mengajukan ulang KRS.`
|
|
||||||
: `${actorName} mengajukan KRS.`;
|
|
||||||
|
|
||||||
const colorClasses =
|
|
||||||
log.status === 'approved'
|
|
||||||
? 'border-primary/30 bg-primary/10'
|
|
||||||
: log.status === 'rejected'
|
|
||||||
? 'border-destructive/30 bg-destructive/10'
|
|
||||||
: 'border-border bg-muted';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rounded-lg border px-3 py-2 ${colorClasses}`}>
|
|
||||||
<p className="text-sm">{message}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{format(new Date(log.created_at), 'd MMM yyyy, HH:mm')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -10,22 +10,23 @@ export const SemesterLabels: Record<Semester, string> = {
|
|||||||
|
|
||||||
export type AcademicTerm = {
|
export type AcademicTerm = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
academic_year: string;
|
||||||
semester: Semester;
|
semester: Semester;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
formatted_start_date: string;
|
formatted_start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
formatted_end_date: string;
|
formatted_end_date: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
open_semesters: number[] | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatAcademicTermLabel(term: {
|
export function formatAcademicTermLabel(term: {
|
||||||
name: string;
|
academic_year: string;
|
||||||
semester: string;
|
semester: string;
|
||||||
}): string {
|
}): string {
|
||||||
const label = SemesterLabels[term.semester as Semester];
|
const label = SemesterLabels[term.semester as Semester];
|
||||||
|
|
||||||
return label ? `${term.name} (${label})` : term.name;
|
return label ? `${term.academic_year} (${label})` : term.academic_year;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export type CourseClass = {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
department_id: number;
|
department_id: number;
|
||||||
|
semester_number: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
lecturer_id: number;
|
lecturer_id: number;
|
||||||
lecturer: {
|
lecturer: {
|
||||||
@ -25,7 +26,11 @@ export type CourseClass = {
|
|||||||
user: { profile: { full_name: string } | null } | null;
|
user: { profile: { full_name: string } | null } | null;
|
||||||
} | null;
|
} | null;
|
||||||
academic_term_id: number;
|
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;
|
method: ClassMethod | null;
|
||||||
enrollments_count: number;
|
enrollments_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@ -15,63 +15,82 @@ export const RegistrationStatusLabels: Record<RegistrationStatus, string> = {
|
|||||||
export type CourseRegistrationStudent = {
|
export type CourseRegistrationStudent = {
|
||||||
id: number;
|
id: number;
|
||||||
student_number: string;
|
student_number: string;
|
||||||
current_semester: number;
|
user: { profile: { full_name: string } | null } | null;
|
||||||
department: { id: number; name: string } | 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;
|
user: { profile: { full_name: string } | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CourseRegistrationCourseClass = {
|
export type CourseRegistrationSubmissionStudent = {
|
||||||
id: number;
|
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: {
|
course: {
|
||||||
id: number;
|
id: number;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
credits: number;
|
||||||
semester_number: number | null;
|
semester_number: number | null;
|
||||||
department_id: number;
|
};
|
||||||
credits?: number;
|
course_class: {
|
||||||
} | null;
|
|
||||||
lecturer?: {
|
|
||||||
user: { profile: { full_name: string } | null } | null;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CourseRegistration = {
|
|
||||||
id: number;
|
id: number;
|
||||||
student_id: number;
|
} | null;
|
||||||
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 = {
|
export type CourseRegistrationSubmissionLog = {
|
||||||
id: number;
|
id: number;
|
||||||
status: RegistrationStatus;
|
status: RegistrationStatus;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
actor: CourseRegistrationActor | null;
|
actor: { profile: { full_name: string } | null } | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CourseRegistrationSubmission = {
|
export type CourseRegistrationSubmission = {
|
||||||
id: number;
|
id: number;
|
||||||
student_id: number;
|
|
||||||
student: CourseRegistrationStudent | null;
|
|
||||||
academic_term_id: number;
|
|
||||||
academic_term: { id: number; name: string; semester: string } | null;
|
|
||||||
status: RegistrationStatus;
|
status: RegistrationStatus;
|
||||||
|
rejection_reason: string | null;
|
||||||
signed_at: string;
|
signed_at: string;
|
||||||
signature_url: string | null;
|
signature_url: string | null;
|
||||||
rejection_reason: string | null;
|
kaprodi_signature_url: string | null;
|
||||||
reviewed_by: number | null;
|
advisor_signature_url: string | null;
|
||||||
reviewer: CourseRegistrationActor | null;
|
has_registrations: boolean;
|
||||||
reviewed_at: string | null;
|
student: CourseRegistrationSubmissionStudent | null;
|
||||||
course_registrations: CourseRegistration[];
|
academic_term: {
|
||||||
|
id: number;
|
||||||
|
academic_year: string;
|
||||||
|
semester: string;
|
||||||
|
} | null;
|
||||||
|
course_registrations: CourseRegistrationSubmissionEntry[];
|
||||||
logs: CourseRegistrationSubmissionLog[];
|
logs: CourseRegistrationSubmissionLog[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function semesterKey(semester: number | null): string {
|
||||||
|
return semester === null ? 'lainnya' : String(semester);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,11 +2,27 @@ export const DegreeLevels = ['D3', 'D4', 'S1', 'S2', 'S3'] as const;
|
|||||||
|
|
||||||
export type DegreeLevel = (typeof DegreeLevels)[number];
|
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 = {
|
export type Department = {
|
||||||
id: number;
|
id: number;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
degree_level: DegreeLevel | null;
|
degree_level: DegreeLevel | null;
|
||||||
|
current_leader: DepartmentLeadership | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,7 +11,11 @@ export type TuitionInvoice = {
|
|||||||
student_id: number;
|
student_id: number;
|
||||||
student: TuitionInvoiceStudent | null;
|
student: TuitionInvoiceStudent | null;
|
||||||
academic_term_id: number;
|
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;
|
amount_due: string;
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
paid_total: string | null;
|
paid_total: string | null;
|
||||||
|
|||||||
@ -98,15 +98,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
||||||
Route::resource('course-registrations', CourseRegistrationController::class)
|
Route::prefix('course-registrations')->name('course-registrations.')->group(function () {
|
||||||
->only(['index', 'store'])
|
Route::get('/', [CourseRegistrationController::class, 'index'])->name('index')->middleware('permission:view-course-registrations');
|
||||||
->middlewareFor(['index'], 'permission:view-course-registrations')
|
Route::patch('submissions/{submission}/approve', [CourseRegistrationController::class, 'approve'])->name('approve')->middleware('permission:approve-course-registrations');
|
||||||
->middlewareFor(['store'], 'permission:create-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::prefix('course-registrations/{submission}')->name('course-registrations.')->group(function () {
|
Route::put('{student}/{semester}', [CourseRegistrationController::class, 'save'])->name('save')->middleware('permission:view-course-registrations');
|
||||||
Route::get('/', [CourseRegistrationController::class, 'show'])->name('show')->middleware('permission:view-course-registrations');
|
Route::patch('{student}/{semester}/kaprodi-signature', [CourseRegistrationController::class, 'signAsKaprodi'])->name('kaprodi_signature')->middleware('permission:view-course-registrations');
|
||||||
Route::patch('approve', [CourseRegistrationController::class, 'approve'])->name('approve')->middleware('permission:approve-course-registrations');
|
Route::patch('{student}/{semester}/advisor-signature', [CourseRegistrationController::class, 'signAsAdvisor'])->name('advisor_signature')->middleware('permission:approve-course-registrations');
|
||||||
Route::patch('reject', [CourseRegistrationController::class, 'reject'])->name('reject')->middleware('permission:reject-course-registrations');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::resource('course-classes', CourseClassController::class)
|
Route::resource('course-classes', CourseClassController::class)
|
||||||
@ -116,9 +115,12 @@
|
|||||||
->middlewareFor(['update'], 'permission:update-course-classes')
|
->middlewareFor(['update'], 'permission:update-course-classes')
|
||||||
->middlewareFor(['destroy'], 'permission:delete-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::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::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');
|
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::prefix('admin/finances')->name('admin.finances.')->group(function () {
|
||||||
Route::resource('tuition-invoices', TuitionInvoiceController::class)
|
Route::resource('tuition-invoices', TuitionInvoiceController::class)
|
||||||
->except(['create', 'edit', 'show'])
|
->except(['create', 'edit', 'show'])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user