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(), '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']),
]); ]);
} }

View File

@ -10,7 +10,13 @@ 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')
->get();
} }
public function getSemesterNumbers(): array public function getSemesterNumbers(): array

View File

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

View File

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