From 015cbc229b81a8e5c5acbd3be00270b80e361b17 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 21 Aug 2026 19:06:05 +0700 Subject: [PATCH] feat: add course and class enrollment management - Implemented ClassEnrollmentIndex component for managing class enrollments, including adding and removing students. - Created CourseClassIndex component for managing course classes with CRUD functionality. - Developed columns for course management in createCourseColumns function. - Added CourseIndex component for managing courses with CRUD operations. - Introduced types for class enrollment and course class to enhance type safety. - Updated routes to include endpoints for managing courses, course classes, and enrollments. --- app/Enums/ClassMethod.php | 19 + .../Manage/ClassEnrollmentController.php | 44 ++ .../Admin/Manage/CourseClassController.php | 60 ++ .../Admin/Manage/CourseController.php | 54 ++ .../Admin/Manage/ClassEnrollmentRequest.php | 29 + .../Admin/Manage/CourseClassRequest.php | 33 ++ .../Requests/Admin/Manage/CourseRequest.php | 30 + app/Models/ClassEnrollment.php | 31 ++ app/Models/Course.php | 26 + app/Models/CourseClass.php | 44 ++ app/Models/Student.php | 6 + .../Admin/Manage/ClassEnrollmentService.php | 43 ++ .../Admin/Manage/CourseClassService.php | 43 ++ app/Services/Admin/Manage/CourseService.php | 47 ++ ...2026_08_21_000001_create_courses_table.php | 27 + ..._21_000002_create_course_classes_table.php | 28 + ..._000003_create_class_enrollments_table.php | 26 + database/seeders/ClassEnrollmentSeeder.php | 29 + database/seeders/CourseClassSeeder.php | 33 ++ database/seeders/CourseSeeder.php | 33 ++ database/seeders/DatabaseSeeder.php | 3 + resources/js/components/app-sidebar.tsx | 27 +- .../admin/manage/course-classes/columns.tsx | 124 +++++ .../manage/course-classes/enrollments.tsx | 301 ++++++++++ .../admin/manage/course-classes/index.tsx | 524 ++++++++++++++++++ .../js/pages/admin/manage/courses/columns.tsx | 105 ++++ .../js/pages/admin/manage/courses/index.tsx | 384 +++++++++++++ resources/js/types/class-enrollment.ts | 16 + resources/js/types/course-class.ts | 34 ++ resources/js/types/course.ts | 11 + routes/admin.php | 14 + 31 files changed, 2227 insertions(+), 1 deletion(-) create mode 100644 app/Enums/ClassMethod.php create mode 100644 app/Http/Controllers/Admin/Manage/ClassEnrollmentController.php create mode 100644 app/Http/Controllers/Admin/Manage/CourseClassController.php create mode 100644 app/Http/Controllers/Admin/Manage/CourseController.php create mode 100644 app/Http/Requests/Admin/Manage/ClassEnrollmentRequest.php create mode 100644 app/Http/Requests/Admin/Manage/CourseClassRequest.php create mode 100644 app/Http/Requests/Admin/Manage/CourseRequest.php create mode 100644 app/Models/ClassEnrollment.php create mode 100644 app/Models/Course.php create mode 100644 app/Models/CourseClass.php create mode 100644 app/Services/Admin/Manage/ClassEnrollmentService.php create mode 100644 app/Services/Admin/Manage/CourseClassService.php create mode 100644 app/Services/Admin/Manage/CourseService.php create mode 100644 database/migrations/2026_08_21_000001_create_courses_table.php create mode 100644 database/migrations/2026_08_21_000002_create_course_classes_table.php create mode 100644 database/migrations/2026_08_21_000003_create_class_enrollments_table.php create mode 100644 database/seeders/ClassEnrollmentSeeder.php create mode 100644 database/seeders/CourseClassSeeder.php create mode 100644 database/seeders/CourseSeeder.php create mode 100644 resources/js/pages/admin/manage/course-classes/columns.tsx create mode 100644 resources/js/pages/admin/manage/course-classes/enrollments.tsx create mode 100644 resources/js/pages/admin/manage/course-classes/index.tsx create mode 100644 resources/js/pages/admin/manage/courses/columns.tsx create mode 100644 resources/js/pages/admin/manage/courses/index.tsx create mode 100644 resources/js/types/class-enrollment.ts create mode 100644 resources/js/types/course-class.ts create mode 100644 resources/js/types/course.ts diff --git a/app/Enums/ClassMethod.php b/app/Enums/ClassMethod.php new file mode 100644 index 0000000..6fa6a0d --- /dev/null +++ b/app/Enums/ClassMethod.php @@ -0,0 +1,19 @@ + 'Daring', + self::Offline => 'Luring', + self::Hybrid => 'Hybrid', + }; + } +} diff --git a/app/Http/Controllers/Admin/Manage/ClassEnrollmentController.php b/app/Http/Controllers/Admin/Manage/ClassEnrollmentController.php new file mode 100644 index 0000000..c473665 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/ClassEnrollmentController.php @@ -0,0 +1,44 @@ +load(['course', 'lecturer.user.profile', 'academicTerm']); + + return Inertia::render('admin/manage/course-classes/enrollments', [ + 'courseClass' => $courseClass, + 'enrollments' => $this->service->forClass($courseClass), + 'availableStudents' => $this->service->availableStudents($courseClass), + ]); + } + + public function store(ClassEnrollmentRequest $request, CourseClass $courseClass): RedirectResponse + { + $this->service->enrollMany($courseClass, $request->validated('student_ids')); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil ditambahkan ke kelas.'])->back(); + } + + public function destroy(CourseClass $courseClass, ClassEnrollment $enrollment): RedirectResponse + { + $this->service->unenroll($enrollment); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil dikeluarkan dari kelas.'])->back(); + } +} diff --git a/app/Http/Controllers/Admin/Manage/CourseClassController.php b/app/Http/Controllers/Admin/Manage/CourseClassController.php new file mode 100644 index 0000000..3304ee1 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/CourseClassController.php @@ -0,0 +1,60 @@ + $this->service->paginated(...$request->validatedWithDefaults()), + 'courses' => $this->courseService->getAll(), + 'lecturers' => $this->lecturerService->getAllForSelect(), + 'academicTerms' => $this->academicTermService->getAll(), + ]); + } + + public function store(CourseClassRequest $request): RedirectResponse + { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil ditambahkan.']); + + return to_route('admin.manage.course-classes.index'); + } + + public function update(CourseClassRequest $request, CourseClass $courseClass): RedirectResponse + { + $this->service->update($courseClass, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil diperbarui.']); + + return to_route('admin.manage.course-classes.index'); + } + + public function destroy(CourseClass $courseClass): RedirectResponse + { + $this->service->delete($courseClass); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil dihapus.'])->back(); + } +} diff --git a/app/Http/Controllers/Admin/Manage/CourseController.php b/app/Http/Controllers/Admin/Manage/CourseController.php new file mode 100644 index 0000000..1bc5c12 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/CourseController.php @@ -0,0 +1,54 @@ + $this->service->paginated(...$request->validatedWithDefaults()), + 'departments' => $this->departmentService->getAll(), + ]); + } + + public function store(CourseRequest $request): RedirectResponse + { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Mata kuliah berhasil ditambahkan.']); + + return to_route('admin.manage.courses.index'); + } + + public function update(CourseRequest $request, Course $course): RedirectResponse + { + $this->service->update($course, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Mata kuliah berhasil diperbarui.']); + + return to_route('admin.manage.courses.index'); + } + + public function destroy(Course $course): RedirectResponse + { + $this->service->delete($course); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mata kuliah berhasil dihapus.'])->back(); + } +} diff --git a/app/Http/Requests/Admin/Manage/ClassEnrollmentRequest.php b/app/Http/Requests/Admin/Manage/ClassEnrollmentRequest.php new file mode 100644 index 0000000..a224331 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/ClassEnrollmentRequest.php @@ -0,0 +1,29 @@ +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), + ], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/CourseClassRequest.php b/app/Http/Requests/Admin/Manage/CourseClassRequest.php new file mode 100644 index 0000000..56e2d75 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CourseClassRequest.php @@ -0,0 +1,33 @@ +input('course_id'))?->department_id; + + return [ + 'course_id' => ['required', 'integer', Rule::exists('courses', 'id')], + 'lecturer_id' => [ + 'required', + 'integer', + Rule::exists('lecturers', 'id')->where('department_id', $departmentId), + ], + 'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], + 'class_name' => ['nullable', 'string', 'max:50'], + 'method' => ['nullable', 'string', Rule::in(array_column(ClassMethod::cases(), 'value'))], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/CourseRequest.php b/app/Http/Requests/Admin/Manage/CourseRequest.php new file mode 100644 index 0000000..40bbe84 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CourseRequest.php @@ -0,0 +1,30 @@ + [ + 'required', + 'string', + 'max:20', + Rule::unique('courses')->ignore($this->route('course')), + ], + 'name' => ['required', 'string', 'max:150'], + 'credits' => ['required', 'integer', 'min:1', 'max:10'], + 'department_id' => ['required', 'integer', Rule::exists('departments', 'id')], + 'semester_number' => ['nullable', 'integer', 'min:1', 'max:14'], + ]; + } +} diff --git a/app/Models/ClassEnrollment.php b/app/Models/ClassEnrollment.php new file mode 100644 index 0000000..601a8ac --- /dev/null +++ b/app/Models/ClassEnrollment.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + } + + public function courseClass(): BelongsTo + { + return $this->belongsTo(CourseClass::class); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } +} diff --git a/app/Models/Course.php b/app/Models/Course.php new file mode 100644 index 0000000..61bcdbe --- /dev/null +++ b/app/Models/Course.php @@ -0,0 +1,26 @@ +belongsTo(Department::class); + } + + public function classes(): HasMany + { + return $this->hasMany(CourseClass::class); + } +} diff --git a/app/Models/CourseClass.php b/app/Models/CourseClass.php new file mode 100644 index 0000000..b306c69 --- /dev/null +++ b/app/Models/CourseClass.php @@ -0,0 +1,44 @@ + ClassMethod::class, + ]; + } + + public function course(): BelongsTo + { + return $this->belongsTo(Course::class); + } + + public function lecturer(): BelongsTo + { + return $this->belongsTo(Lecturer::class); + } + + public function academicTerm(): BelongsTo + { + return $this->belongsTo(AcademicTerm::class); + } + + public function enrollments(): HasMany + { + return $this->hasMany(ClassEnrollment::class); + } +} diff --git a/app/Models/Student.php b/app/Models/Student.php index 4197293..5f252bf 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] @@ -35,4 +36,9 @@ public function academicAdvisor(): BelongsTo { return $this->belongsTo(Lecturer::class, 'academic_advisor_id'); } + + public function enrollments(): HasMany + { + return $this->hasMany(ClassEnrollment::class); + } } diff --git a/app/Services/Admin/Manage/ClassEnrollmentService.php b/app/Services/Admin/Manage/ClassEnrollmentService.php new file mode 100644 index 0000000..3f22136 --- /dev/null +++ b/app/Services/Admin/Manage/ClassEnrollmentService.php @@ -0,0 +1,43 @@ +enrollments() + ->with(['student.user.profile', 'student.department']) + ->latest('enrolled_at') + ->get(); + } + + public function availableStudents(CourseClass $courseClass): Collection + { + return Student::query() + ->where('department_id', $courseClass->course->department_id) + ->whereDoesntHave('enrollments', fn ($q) => $q->where('course_class_id', $courseClass->id)) + ->with(['user.profile', 'department']) + ->get(); + } + + public function enrollMany(CourseClass $courseClass, array $studentIds): void + { + foreach ($studentIds as $studentId) { + $courseClass->enrollments()->firstOrCreate( + ['student_id' => $studentId], + ['enrolled_at' => now()], + ); + } + } + + public function unenroll(ClassEnrollment $enrollment): bool + { + return $enrollment->delete(); + } +} diff --git a/app/Services/Admin/Manage/CourseClassService.php b/app/Services/Admin/Manage/CourseClassService.php new file mode 100644 index 0000000..938abb6 --- /dev/null +++ b/app/Services/Admin/Manage/CourseClassService.php @@ -0,0 +1,43 @@ +select(['id', 'course_id', 'lecturer_id', 'academic_term_id', 'class_name', 'method']) + ->withCount('enrollments') + ->with(['course:id,code,name,department_id', 'lecturer.user.profile', 'academicTerm:id,name,semester,start_date,end_date']) + ->when($search, fn ($q) => $q->where('class_name', 'like', "%{$search}%") + ->orWhereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))) + ->orderBy($sort, $direction) + ->paginate($perPage); + } + + public function create(array $data): CourseClass + { + return CourseClass::create($data); + } + + public function update(CourseClass $courseClass, array $data): CourseClass + { + $courseClass->course_id = $data['course_id']; + $courseClass->lecturer_id = $data['lecturer_id']; + $courseClass->academic_term_id = $data['academic_term_id']; + $courseClass->class_name = $data['class_name'] ?? null; + $courseClass->method = $data['method'] ?? null; + $courseClass->update(); + + return $courseClass; + } + + public function delete(CourseClass $courseClass): bool + { + return $courseClass->delete(); + } +} diff --git a/app/Services/Admin/Manage/CourseService.php b/app/Services/Admin/Manage/CourseService.php new file mode 100644 index 0000000..2a42ef1 --- /dev/null +++ b/app/Services/Admin/Manage/CourseService.php @@ -0,0 +1,47 @@ +get(); + } + + public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator + { + return Course::query() + ->select(['id', 'code', 'name', 'credits', 'department_id', 'semester_number']) + ->with('department:id,name') + ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")) + ->orderBy($sort, $direction) + ->paginate($perPage); + } + + public function create(array $data): Course + { + return Course::create($data); + } + + public function update(Course $course, array $data): Course + { + $course->code = $data['code']; + $course->name = $data['name']; + $course->credits = $data['credits']; + $course->department_id = $data['department_id']; + $course->semester_number = $data['semester_number'] ?? null; + $course->update(); + + return $course; + } + + public function delete(Course $course): bool + { + return $course->delete(); + } +} diff --git a/database/migrations/2026_08_21_000001_create_courses_table.php b/database/migrations/2026_08_21_000001_create_courses_table.php new file mode 100644 index 0000000..9c36cbc --- /dev/null +++ b/database/migrations/2026_08_21_000001_create_courses_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('code', 20)->unique(); + $table->string('name', 150); + $table->integer('credits'); + $table->foreignId('department_id')->constrained()->cascadeOnDelete(); + $table->integer('semester_number')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('courses'); + } +}; diff --git a/database/migrations/2026_08_21_000002_create_course_classes_table.php b/database/migrations/2026_08_21_000002_create_course_classes_table.php new file mode 100644 index 0000000..f7eb968 --- /dev/null +++ b/database/migrations/2026_08_21_000002_create_course_classes_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('course_id')->constrained()->cascadeOnDelete(); + $table->foreignId('lecturer_id')->constrained()->cascadeOnDelete(); + $table->foreignId('academic_term_id')->constrained()->cascadeOnDelete(); + $table->string('class_name', 50)->nullable(); + $table->enum('method', array_values(ClassMethod::cases()))->nullable()->default(ClassMethod::Hybrid->value); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('course_classes'); + } +}; diff --git a/database/migrations/2026_08_21_000003_create_class_enrollments_table.php b/database/migrations/2026_08_21_000003_create_class_enrollments_table.php new file mode 100644 index 0000000..953504b --- /dev/null +++ b/database/migrations/2026_08_21_000003_create_class_enrollments_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('course_class_id')->constrained()->cascadeOnDelete(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->timestamp('enrolled_at')->nullable(); + $table->timestamps(); + + $table->unique(['course_class_id', 'student_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('class_enrollments'); + } +}; diff --git a/database/seeders/ClassEnrollmentSeeder.php b/database/seeders/ClassEnrollmentSeeder.php new file mode 100644 index 0000000..40f9b44 --- /dev/null +++ b/database/seeders/ClassEnrollmentSeeder.php @@ -0,0 +1,29 @@ + 1, + 'student_id' => 1, + 'enrolled_at' => '2026-02-02 08:00:00', + 'created_at' => '2026-02-02 08:00:00', + 'updated_at' => '2026-02-02 08:00:00', + ], + [ + 'course_class_id' => 2, + 'student_id' => 2, + 'enrolled_at' => '2026-02-02 08:15:00', + 'created_at' => '2026-02-02 08:15:00', + 'updated_at' => '2026-02-02 08:15:00', + ], + ]); + } +} diff --git a/database/seeders/CourseClassSeeder.php b/database/seeders/CourseClassSeeder.php new file mode 100644 index 0000000..d408ab2 --- /dev/null +++ b/database/seeders/CourseClassSeeder.php @@ -0,0 +1,33 @@ + 1, + 'lecturer_id' => 1, + 'academic_term_id' => 2, + 'class_name' => 'SI-5A', + 'method' => 'hybrid', + 'created_at' => '2026-01-10 08:00:00', + 'updated_at' => '2026-01-10 08:00:00', + ], + [ + 'course_id' => 2, + 'lecturer_id' => 2, + 'academic_term_id' => 2, + 'class_name' => 'TI-3A', + 'method' => 'offline', + 'created_at' => '2026-01-10 08:00:00', + 'updated_at' => '2026-01-10 08:00:00', + ], + ]); + } +} diff --git a/database/seeders/CourseSeeder.php b/database/seeders/CourseSeeder.php new file mode 100644 index 0000000..31aa9f4 --- /dev/null +++ b/database/seeders/CourseSeeder.php @@ -0,0 +1,33 @@ + 'SI301', + 'name' => 'Pemrograman Web', + 'credits' => 3, + 'department_id' => 1, + 'semester_number' => 5, + 'created_at' => '2024-02-01 08:00:00', + 'updated_at' => '2024-02-01 08:00:00', + ], + [ + 'code' => 'TI201', + 'name' => 'Manajemen Kualitas', + 'credits' => 3, + 'department_id' => 2, + 'semester_number' => 3, + 'created_at' => '2024-02-01 08:00:00', + 'updated_at' => '2024-02-01 08:00:00', + ], + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 5fb7cbf..92f215e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,9 @@ public function run(): void UserSeeder::class, AcademicTermSeeder::class, DepartmentLeadershipSeeder::class, + CourseSeeder::class, + CourseClassSeeder::class, + ClassEnrollmentSeeder::class, ]); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 02176d8..04235f3 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -19,12 +19,22 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar'; +import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes'; +import { index as coursesRoute } from '@/routes/admin/manage/courses'; import { index as academicTerm } from '@/routes/admin/master/academic-terms'; import { index as departmentsRoute } from '@/routes/admin/master/departments'; import { index as lecturersRoute } from '@/routes/admin/users/lecturers'; import { index as studentsRoute } from '@/routes/admin/users/students'; import { index as administratorsRoute } from '@/routes/admin/users/administrators'; -import { Building2, Calendar, GraduationCap, User, Users } from 'lucide-react'; +import { + BookOpen, + Building2, + Calendar, + GraduationCap, + School, + User, + Users, +} from 'lucide-react'; const data: { navMain: (NavGroup | NavItem)[]; @@ -51,6 +61,21 @@ const data: { }, ], }, + { + label: 'Kelola', + items: [ + { + name: 'Mata Kuliah', + url: coursesRoute.url(), + icon: BookOpen, + }, + { + name: 'Kelas Mata Kuliah', + url: courseClassesRoute.url(), + icon: School, + }, + ], + }, { label: 'Pengguna', items: [ diff --git a/resources/js/pages/admin/manage/course-classes/columns.tsx b/resources/js/pages/admin/manage/course-classes/columns.tsx new file mode 100644 index 0000000..d2e60f1 --- /dev/null +++ b/resources/js/pages/admin/manage/course-classes/columns.tsx @@ -0,0 +1,124 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { Pencil, Trash2, Users } from 'lucide-react'; +import { RowActions } from '@/components/row-actions'; +import { Badge } from '@/components/ui/badge'; +import { index as enrollmentsIndex } from '@/routes/admin/manage/course-classes/enrollments'; +import { ClassMethodLabels } from '@/types/course-class'; +import type { CourseClass } from '@/types/course-class'; + +export type { CourseClass } from '@/types/course-class'; + +type CreateColumnsParams = { + handleEdit: (courseClass: CourseClass) => void; + handleDeleteClick: (courseClass: CourseClass) => void; +}; + +export function createCourseClassColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + accessorKey: 'class_name', + header: () => Kelas, + cell: ({ row }) => ( + + {(row.getValue('class_name') as string | null) ?? '-'} + + ), + }, + { + accessorKey: 'course.name', + header: () => Mata Kuliah, + cell: ({ row }) => { + const course = row.original.course; + + return course ? `${course.code} - ${course.name}` : '-'; + }, + }, + { + accessorKey: 'lecturer.user.profile.full_name', + header: () => Dosen Pengampu, + cell: ({ row }) => + row.original.lecturer?.user?.profile?.full_name ?? '-', + }, + { + accessorKey: 'academic_term.name', + header: () => Periode, + cell: ({ row }) => row.original.academic_term?.name ?? '-', + }, + { + accessorKey: 'method', + header: () => Metode, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const method = row.getValue('method') as + keyof typeof ClassMethodLabels | null; + + return ( +
+ {method ? ( + + {ClassMethodLabels[method]} + + ) : ( + - + )} +
+ ); + }, + }, + { + accessorKey: 'enrollments_count', + header: () => Peserta, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => ( +
+ {row.original.enrollments_count} +
+ ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[130px] text-center', + headerClassName: 'w-[130px] text-center', + }, + cell: ({ row }) => { + const courseClass = row.original; + + return ( + , + href: enrollmentsIndex.url(courseClass.id), + }, + { + label: 'Edit', + icon: , + onClick: () => handleEdit(courseClass), + }, + { + label: 'Hapus', + icon: ( + + ), + onClick: () => handleDeleteClick(courseClass), + }, + ]} + /> + ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/manage/course-classes/enrollments.tsx b/resources/js/pages/admin/manage/course-classes/enrollments.tsx new file mode 100644 index 0000000..8ac0f0e --- /dev/null +++ b/resources/js/pages/admin/manage/course-classes/enrollments.tsx @@ -0,0 +1,301 @@ +import { Head, router } from '@inertiajs/react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; +import { Trash2, UserPlus } from 'lucide-react'; +import { useState } from 'react'; +import { DataTable } from '@/components/data-table'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { FormDialog } from '@/components/form-dialog'; +import InputError from '@/components/input-error'; +import { PageHeader } from '@/components/page-header'; +import { RowActions } from '@/components/row-actions'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { index as courseClassIndex } from '@/routes/admin/manage/course-classes'; +import { + destroy, + store, +} from '@/routes/admin/manage/course-classes/enrollments'; +import type { + ClassEnrollment, + EnrollmentStudent, +} from '@/types/class-enrollment'; +import type { CourseClass } from '@/types/course-class'; +import { ClassMethodLabels } from '@/types/course-class'; + +type Props = { + courseClass: CourseClass; + enrollments: ClassEnrollment[]; + availableStudents: EnrollmentStudent[]; +}; + +export default function ClassEnrollmentIndex({ + courseClass, + enrollments, + availableStudents, +}: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [deleting, setDeleting] = useState(null); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy([courseClass.id, deleting.id]), { + onSuccess: () => setDeleting(null), + }); + } + + const columns: ColumnDef[] = [ + { + accessorKey: 'student.student_number', + header: () => NIM, + cell: ({ row }) => row.original.student?.student_number ?? '-', + }, + { + accessorKey: 'student.user.profile.full_name', + header: () => Nama Mahasiswa, + cell: ({ row }) => + row.original.student?.user?.profile?.full_name ?? '-', + }, + { + accessorKey: 'student.department.name', + header: () => Jurusan, + cell: ({ row }) => row.original.student?.department?.name ?? '-', + }, + { + accessorKey: 'enrolled_at', + header: () => Tanggal Daftar, + cell: ({ row }) => { + const enrolledAt = row.getValue('enrolled_at') as string | null; + + return enrolledAt + ? format(new Date(enrolledAt), 'd MMM yyyy') + : '-'; + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row }) => ( + + ), + onClick: () => setDeleting(row.original), + }, + ]} + /> + ), + }, + ]; + + return ( + <> + + +
+ + Kembali + + } + /> + + + + + {courseClass.class_name ?? courseClass.course?.name} + + + +

+ Mata Kuliah: {courseClass.course?.code} -{' '} + {courseClass.course?.name} +

+

+ Dosen Pengampu:{' '} + {courseClass.lecturer?.user?.profile?.full_name ?? + '-'} +

+

Periode: {courseClass.academic_term?.name}

+

+ Metode:{' '} + {courseClass.method + ? ClassMethodLabels[courseClass.method] + : '-'} +

+

Jumlah Peserta: {enrollments.length}

+
+
+ +
+

Daftar Mahasiswa

+ +
+ + + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Keluarkan Mahasiswa" + description={(enrollment) => + `Apakah Anda yakin ingin mengeluarkan "${enrollment.student?.user?.profile?.full_name ?? 'mahasiswa ini'}" dari kelas ini?` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function EnrollForm({ + open, + onOpenChange, + courseClassId, + availableStudents, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + courseClassId: number; + availableStudents: EnrollmentStudent[]; +}) { + const [selectedIds, setSelectedIds] = useState([]); + + 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 ( + 0 + ? `Tambahkan (${selectedIds.length})` + : 'Tambahkan' + } + onSuccess={() => { + onOpenChange(false); + setSelectedIds([]); + }} + > + {({ errors }) => ( +
+
+
+ + {availableStudents.length > 0 && ( +
+ + +
+ )} +
+ {selectedIds.map((id) => ( + + ))} +
+ {availableStudents.length === 0 ? ( +

+ Tidak ada mahasiswa dari jurusan yang sama + untuk didaftarkan. +

+ ) : ( + availableStudents.map((student) => ( + + )) + )} +
+ +
+
+ )} +
+ ); +} diff --git a/resources/js/pages/admin/manage/course-classes/index.tsx b/resources/js/pages/admin/manage/course-classes/index.tsx new file mode 100644 index 0000000..e9119d4 --- /dev/null +++ b/resources/js/pages/admin/manage/course-classes/index.tsx @@ -0,0 +1,524 @@ +import { Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import type { PaginationState } from '@/components/data-table'; +import { DataTable } from '@/components/data-table'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { FormDialog } from '@/components/form-dialog'; +import InputError from '@/components/input-error'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useServerTable } from '@/hooks/use-server-table'; +import { + index as courseClassIndex, + destroy, + store, + update, +} from '@/routes/admin/manage/course-classes'; +import { ClassMethodLabels, ClassMethods } from '@/types/course-class'; +import type { CourseClass } from '@/types/course-class'; +import { createCourseClassColumns } from './columns'; + +type Course = { id: number; code: string; name: string; department_id: number }; +type Lecturer = { + id: number; + lecturer_number: string; + department_id: number; + user: { profile: { full_name: string } | null } | null; +}; +type AcademicTerm = { id: number; name: string; semester: string }; + +type Props = { + courseClasses: { + data: CourseClass[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + courses: Course[]; + lecturers: Lecturer[]; + academicTerms: AcademicTerm[]; + highlight?: number; +}; + +export default function CourseClassIndex({ + courseClasses, + courses, + lecturers, + academicTerms, + highlight, +}: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const pagination: PaginationState = { + current_page: courseClasses.current_page, + last_page: courseClasses.last_page, + per_page: courseClasses.per_page, + total: courseClasses.total, + }; + + const { + search, + handlePageChange, + handlePerPageChange, + handleSearchChange, + } = useServerTable({ + route: () => courseClassIndex.url(), + pagination, + }); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createCourseClassColumns({ + handleEdit: (courseClass) => setEditing(courseClass), + handleDeleteClick: (courseClass) => setDeleting(courseClass), + }); + + return ( + <> + + +
+ + Menampilkan kelas mata kuliah dari notifikasi. + +

+ ) + } + actions={ + + + } + /> + + + + { + if (!open) { + setEditing(null); + } + }} + editing={editing} + courses={courses} + lecturers={lecturers} + academicTerms={academicTerms} + /> + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Kelas Mata Kuliah" + description={(courseClass) => + `Apakah Anda yakin ingin menghapus kelas "${courseClass.class_name ?? courseClass.course?.name}"? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function CreateForm({ + open, + onOpenChange, + courses, + lecturers, + academicTerms, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + courses: Course[]; + lecturers: Lecturer[]; + academicTerms: AcademicTerm[]; +}) { + const [courseId, setCourseId] = useState(''); + const [lecturerId, setLecturerId] = useState(''); + + const selectedCourse = courses.find((c) => String(c.id) === courseId); + const availableLecturers = selectedCourse + ? lecturers.filter( + (l) => l.department_id === selectedCourse.department_id, + ) + : lecturers; + + function reset() { + setCourseId(''); + setLecturerId(''); + } + + return ( + { + onOpenChange(false); + reset(); + }} + > + {({ errors }) => ( +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + +
+
+ + + + +
+
+ )} +
+ ); +} + +function EditForm({ + open, + onOpenChange, + editing, + courses, + lecturers, + academicTerms, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: CourseClass | null; + courses: Course[]; + lecturers: Lecturer[]; + academicTerms: AcademicTerm[]; +}) { + const [courseId, setCourseId] = useState( + editing ? String(editing.course_id) : '', + ); + const [lecturerId, setLecturerId] = useState( + editing ? String(editing.lecturer_id) : '', + ); + + const selectedCourse = courses.find((c) => String(c.id) === courseId); + const availableLecturers = selectedCourse + ? lecturers.filter( + (l) => l.department_id === selectedCourse.department_id, + ) + : lecturers; + + return ( + onOpenChange(false)} + > + {({ errors }) => + editing && ( +
+
+ + + + +
+
+ + + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ ) + } +
+ ); +} diff --git a/resources/js/pages/admin/manage/courses/columns.tsx b/resources/js/pages/admin/manage/courses/columns.tsx new file mode 100644 index 0000000..4cedae4 --- /dev/null +++ b/resources/js/pages/admin/manage/courses/columns.tsx @@ -0,0 +1,105 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { Pencil, Trash2 } from 'lucide-react'; +import { RowActions } from '@/components/row-actions'; +import { Badge } from '@/components/ui/badge'; +import type { Course } from '@/types/course'; + +export type { Course } from '@/types/course'; + +type CreateColumnsParams = { + handleEdit: (course: Course) => void; + handleDeleteClick: (course: Course) => void; +}; + +export function createCourseColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + accessorKey: 'code', + header: () => Kode, + cell: ({ row }) => ( + + {row.getValue('code') as string} + + ), + }, + { + accessorKey: 'name', + header: () => Nama Mata Kuliah, + cell: ({ row }) => {row.getValue('name') as string}, + }, + { + accessorKey: 'department.name', + header: () => Jurusan, + cell: ({ row }) => row.original.department?.name ?? '-', + }, + { + accessorKey: 'credits', + header: () => SKS, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row }) => ( +
+ {row.getValue('credits') as number} +
+ ), + }, + { + accessorKey: 'semester_number', + header: () => Semester, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const semesterNumber = row.getValue('semester_number') as + number | null; + + return ( +
+ {semesterNumber ? ( + {semesterNumber} + ) : ( + - + )} +
+ ); + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const course = row.original; + + return ( + , + onClick: () => handleEdit(course), + }, + { + label: 'Hapus', + icon: ( + + ), + onClick: () => handleDeleteClick(course), + }, + ]} + /> + ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/manage/courses/index.tsx b/resources/js/pages/admin/manage/courses/index.tsx new file mode 100644 index 0000000..1afd50e --- /dev/null +++ b/resources/js/pages/admin/manage/courses/index.tsx @@ -0,0 +1,384 @@ +import { Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import type { PaginationState } from '@/components/data-table'; +import { DataTable } from '@/components/data-table'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { FormDialog } from '@/components/form-dialog'; +import InputError from '@/components/input-error'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useServerTable } from '@/hooks/use-server-table'; +import { + index as courseIndex, + destroy, + store, + update, +} from '@/routes/admin/manage/courses'; +import type { Course } from '@/types/course'; +import { createCourseColumns } from './columns'; + +type Department = { id: number; name: string }; + +type Props = { + courses: { + data: Course[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + departments: Department[]; + highlight?: number; +}; + +export default function CourseIndex({ + courses, + departments, + highlight, +}: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const pagination: PaginationState = { + current_page: courses.current_page, + last_page: courses.last_page, + per_page: courses.per_page, + total: courses.total, + }; + + const { + search, + handlePageChange, + handlePerPageChange, + handleSearchChange, + } = useServerTable({ + route: () => courseIndex.url(), + pagination, + }); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createCourseColumns({ + handleEdit: (course) => setEditing(course), + handleDeleteClick: (course) => setDeleting(course), + }); + + return ( + <> + + +
+ + Menampilkan mata kuliah dari notifikasi. + +

+ ) + } + actions={ + + + } + /> + + + + { + if (!open) { + setEditing(null); + } + }} + editing={editing} + departments={departments} + /> + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Mata Kuliah" + description={(course) => + `Apakah Anda yakin ingin menghapus mata kuliah "${course.name}"? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function CreateForm({ + open, + onOpenChange, + departments, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + departments: Department[]; +}) { + return ( + onOpenChange(false)} + > + {({ errors }) => ( +
+
+ + + +
+
+ + + +
+
+ + + + +
+
+
+ + + +
+
+ + + +
+
+
+ )} +
+ ); +} + +function EditForm({ + open, + onOpenChange, + editing, + departments, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: Course | null; + departments: Department[]; +}) { + return ( + onOpenChange(false)} + > + {({ errors }) => + editing && ( +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + +
+
+ + + +
+
+
+ ) + } +
+ ); +} diff --git a/resources/js/types/class-enrollment.ts b/resources/js/types/class-enrollment.ts new file mode 100644 index 0000000..2f96ee1 --- /dev/null +++ b/resources/js/types/class-enrollment.ts @@ -0,0 +1,16 @@ +export type EnrollmentStudent = { + id: number; + student_number: string; + department: { id: number; name: string } | null; + user: { profile: { full_name: string } | null } | null; +}; + +export type ClassEnrollment = { + id: number; + course_class_id: number; + student_id: number; + student: EnrollmentStudent | null; + enrolled_at: string | null; + created_at: string; + updated_at: string; +}; diff --git a/resources/js/types/course-class.ts b/resources/js/types/course-class.ts new file mode 100644 index 0000000..82dfaf0 --- /dev/null +++ b/resources/js/types/course-class.ts @@ -0,0 +1,34 @@ +export const ClassMethods = ['online', 'offline', 'hybrid'] as const; + +export type ClassMethod = (typeof ClassMethods)[number]; + +export const ClassMethodLabels: Record = { + online: 'Daring', + offline: 'Luring', + hybrid: 'Hybrid', +}; + +export type CourseClass = { + id: number; + course_id: number; + course: { + id: number; + code: string; + name: string; + department_id: number; + } | null; + lecturer_id: number; + lecturer: { + id: number; + lecturer_number: string; + department_id: number; + user: { profile: { full_name: string } | null } | null; + } | null; + academic_term_id: number; + academic_term: { id: number; name: string; semester: string } | null; + class_name: string | null; + method: ClassMethod | null; + enrollments_count: number; + created_at: string; + updated_at: string; +}; diff --git a/resources/js/types/course.ts b/resources/js/types/course.ts new file mode 100644 index 0000000..f4479cc --- /dev/null +++ b/resources/js/types/course.ts @@ -0,0 +1,11 @@ +export type Course = { + id: number; + code: string; + name: string; + credits: number; + department_id: number; + department: { id: number; name: string } | null; + semester_number: number | null; + created_at: string; + updated_at: string; +}; diff --git a/routes/admin.php b/routes/admin.php index 876ca2c..3b99415 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,5 +1,8 @@ except(['create', 'edit', 'show']); }); + Route::prefix('admin/manage')->name('admin.manage.')->group(function () { + Route::resource('courses', CourseController::class)->except(['create', 'edit', 'show']); + Route::resource('course-classes', CourseClassController::class)->except(['create', 'edit', 'show']); + + Route::prefix('course-classes/{course_class}/enrollments')->name('course-classes.enrollments.')->group(function () { + Route::get('/', [ClassEnrollmentController::class, 'index'])->name('index'); + Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store'); + Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy'); + }); + }); + Route::prefix('admin/users')->name('admin.users.')->group(function () { Route::resource('lecturers', LecturerController::class)->except(['show'])->parameters(['lecturers' => 'user']); Route::patch('lecturers/{user}/reset-password', [LecturerController::class, 'resetPassword'])->name('lecturers.reset_password');