feat: enhance course class management by adding course class assignments and improving course selection UI

This commit is contained in:
Yoga Pangestu 2026-09-02 15:07:41 +07:00
parent 28a25fabfd
commit 584056e379
4 changed files with 204 additions and 69 deletions

View File

@ -34,6 +34,7 @@ public function index(PaginatedRequest $request): Response
'courses' => $this->courseService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClassAssignments' => $this->service->getAllForSelect(),
'filters' => $request->only(['academic_term_id', 'method']),
]);
}

View File

@ -10,7 +10,13 @@ class CourseService
{
public function getAllForSelect(): Collection
{
return Course::select(['id', 'code', 'name', 'department_id'])->get();
return Course::query()
->select(['courses.id', 'courses.code', 'courses.name', 'courses.department_id', 'courses.semester_number'])
->join('departments', 'departments.id', '=', 'courses.department_id')
->with('department:id,name')
->orderBy('departments.name')
->orderBy('courses.semester_number')
->get();
}
public function getSemesterNumbers(): array

View File

@ -112,15 +112,6 @@ function buildNavMain({
];
const kelolaItems: NavItem[] = [
...(can('view-course-registrations')
? [
{
name: 'Registrasi KRS',
url: courseRegistrationsRoute.url(),
icon: FileCheck2,
},
]
: []),
...(can('view-course-classes')
? [
{
@ -130,6 +121,15 @@ function buildNavMain({
},
]
: []),
...(can('view-course-registrations')
? [
{
name: 'Registrasi KRS',
url: courseRegistrationsRoute.url(),
icon: FileCheck2,
},
]
: []),
...(can('view-announcements')
? [
{

View File

@ -12,10 +12,13 @@ import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
@ -39,7 +42,15 @@ import { ClassMethodLabels, ClassMethods } from '@/types/course-class';
import type { CourseClass } from '@/types/course-class';
import { createCourseClassColumns } from './columns';
type Course = { id: number; code: string; name: string; department_id: number };
type Course = {
id: number;
code: string;
name: string;
department_id: number;
department: { id: number; name: string } | null;
semester_number: number | null;
};
type CourseGroup = { value: string; items: Course[] };
type Lecturer = {
id: number;
lecturer_number: string;
@ -51,6 +62,29 @@ type AcademicTerm = {
academic_year: string;
semester: string;
};
type CourseClassAssignment = {
id: number;
course_id: number;
academic_term_id: number;
};
function groupCoursesByDepartmentAndSemester(courses: Course[]): CourseGroup[] {
const groups: CourseGroup[] = [];
let currentKey: string | null = null;
for (const course of courses) {
const key = `${course.department?.name ?? 'Tanpa Jurusan'} — Semester ${course.semester_number ?? 'Tidak ditentukan'}`;
if (key !== currentKey) {
currentKey = key;
groups.push({ value: key, items: [] });
}
groups[groups.length - 1].items.push(course);
}
return groups;
}
type Props = {
courseClasses: {
@ -63,6 +97,7 @@ type Props = {
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
highlight?: number;
filters: {
academic_term_id?: string;
@ -75,6 +110,7 @@ export default function CourseClassIndex({
courses,
lecturers,
academicTerms,
courseClassAssignments,
highlight,
filters,
}: Props) {
@ -193,6 +229,7 @@ export default function CourseClassIndex({
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<EditForm
@ -207,6 +244,7 @@ export default function CourseClassIndex({
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<DataTable
@ -251,28 +289,37 @@ function CreateForm({
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [courseId, setCourseId] = useState('');
const [academicTermId, setAcademicTermId] = useState('');
const [course, setCourse] = useState<Course | null>(null);
const [lecturer, setLecturer] = useState<Lecturer | null>(null);
const selectedCourse = courses.find((c) => String(c.id) === courseId);
const availableLecturers = selectedCourse
const takenCourseIds = new Set(
courseClassAssignments
.filter((a) => String(a.academic_term_id) === academicTermId)
.map((a) => a.course_id),
);
const availableCourses = courses.filter((c) => !takenCourseIds.has(c.id));
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
const availableLecturers = course
? lecturers.filter((l) =>
l.departments.some(
(department) =>
department.id === selectedCourse.department_id,
(department) => department.id === course.department_id,
),
)
: lecturers;
function reset() {
setCourseId('');
setAcademicTermId('');
setCourse(null);
setLecturer(null);
}
@ -295,8 +342,19 @@ function CreateForm({
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input type="hidden" name="academic_term_id" />
<Select name="academic_term_id">
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={(value) => {
setAcademicTermId(value);
setCourse(null);
setLecturer(null);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
@ -321,29 +379,50 @@ function CreateForm({
<input
type="hidden"
name="course_id"
value={courseId}
value={course?.id ?? ''}
/>
<Select
value={courseId}
<Combobox
items={courseGroups}
value={course}
onValueChange={(value) => {
setCourseId(value);
setCourse(value);
setLecturer(null);
}}
itemToStringLabel={(c) => `${c.code} - ${c.name}`}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<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>
<ComboboxInput
placeholder="Pilih mata kuliah"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Mata kuliah tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(group: CourseGroup) => (
<ComboboxGroup
key={group.value}
items={group.items}
>
<ComboboxLabel>
{group.value}
</ComboboxLabel>
<ComboboxCollection>
{(c: Course) => (
<ComboboxItem
key={c.id}
value={c}
>
{c.code} - {c.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.course_id} />
</div>
<div className="grid gap-2">
@ -364,12 +443,12 @@ function CreateForm({
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!selectedCourse}
disabled={!course}
>
<ComboboxInput
disabled={!selectedCourse}
disabled={!course}
placeholder={
selectedCourse
course
? 'Pilih dosen pengampu'
: 'Pilih mata kuliah terlebih dahulu'
}
@ -425,6 +504,7 @@ function EditForm({
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -432,9 +512,15 @@ function EditForm({
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [courseId, setCourseId] = useState(
editing ? String(editing.course_id) : '',
const [academicTermId, setAcademicTermId] = useState(
editing ? String(editing.academic_term_id) : '',
);
const [course, setCourse] = useState<Course | null>(
editing
? (courses.find((c) => c.id === editing.course_id) ?? null)
: null,
);
const [lecturer, setLecturer] = useState<Lecturer | null>(
editing
@ -442,12 +528,21 @@ function EditForm({
: null,
);
const selectedCourse = courses.find((c) => String(c.id) === courseId);
const availableLecturers = selectedCourse
const takenCourseIds = new Set(
courseClassAssignments
.filter(
(a) =>
String(a.academic_term_id) === academicTermId &&
a.id !== editing?.id,
)
.map((a) => a.course_id),
);
const availableCourses = courses.filter((c) => !takenCourseIds.has(c.id));
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
const availableLecturers = course
? lecturers.filter((l) =>
l.departments.some(
(department) =>
department.id === selectedCourse.department_id,
(department) => department.id === course.department_id,
),
)
: lecturers;
@ -472,29 +567,53 @@ function EditForm({
<input
type="hidden"
name="course_id"
value={courseId}
value={course?.id ?? ''}
/>
<Select
value={courseId}
<Combobox
items={courseGroups}
value={course}
onValueChange={(value) => {
setCourseId(value);
setCourse(value);
setLecturer(null);
}}
itemToStringLabel={(c) =>
`${c.code} - ${c.name}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<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>
<ComboboxInput
placeholder="Pilih mata kuliah"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Mata kuliah tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(group: CourseGroup) => (
<ComboboxGroup
key={group.value}
items={group.items}
>
<ComboboxLabel>
{group.value}
</ComboboxLabel>
<ComboboxCollection>
{(c: Course) => (
<ComboboxItem
key={c.id}
value={c}
>
{c.code} -{' '}
{c.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.course_id} />
</div>
<div className="grid gap-2">
@ -515,12 +634,12 @@ function EditForm({
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!selectedCourse}
disabled={!course}
>
<ComboboxInput
disabled={!selectedCourse}
disabled={!course}
placeholder={
selectedCourse
course
? 'Pilih dosen pengampu'
: 'Pilih mata kuliah terlebih dahulu'
}
@ -551,9 +670,18 @@ function EditForm({
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<Select
<input
type="hidden"
name="academic_term_id"
defaultValue={String(editing.academic_term_id)}
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={(value) => {
setAcademicTermId(value);
setCourse(null);
setLecturer(null);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />