feat: implement bulk creation of course classes and enhance validation for lecturer assignment

This commit is contained in:
Yoga Pangestu 2026-09-02 15:34:55 +07:00
parent 584056e379
commit 03c3fc61af
4 changed files with 119 additions and 32 deletions

View File

@ -41,9 +41,14 @@ public function index(PaginatedRequest $request): Response
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');
} }

View File

@ -4,6 +4,7 @@
use App\Enums\ClassMethod; use App\Enums\ClassMethod;
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 +17,51 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
$academicTermId = $this->input('academic_term_id');
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'),
],
'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),
],
'lecturer_id' => [ 'lecturer_id' => [
'required', 'required',
'integer', 'integer',

View File

@ -28,9 +28,18 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
->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

View File

@ -12,6 +12,9 @@ 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, ComboboxCollection,
ComboboxContent, ComboboxContent,
ComboboxEmpty, ComboboxEmpty,
@ -20,6 +23,7 @@ import {
ComboboxItem, ComboboxItem,
ComboboxLabel, 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 {
@ -299,27 +303,37 @@ function CreateForm({
courseClassAssignments: CourseClassAssignment[]; courseClassAssignments: CourseClassAssignment[];
}) { }) {
const [academicTermId, setAcademicTermId] = useState(''); const [academicTermId, setAcademicTermId] = useState('');
const [course, setCourse] = useState<Course | null>(null); const [selectedCourses, setSelectedCourses] = useState<Course[]>([]);
const [lecturer, setLecturer] = useState<Lecturer | null>(null); const [lecturer, setLecturer] = useState<Lecturer | null>(null);
const courseAnchor = useComboboxAnchor();
const takenCourseIds = new Set( const takenCourseIds = new Set(
courseClassAssignments courseClassAssignments
.filter((a) => String(a.academic_term_id) === academicTermId) .filter((a) => String(a.academic_term_id) === academicTermId)
.map((a) => a.course_id), .map((a) => a.course_id),
); );
const availableCourses = courses.filter((c) => !takenCourseIds.has(c.id)); const selectedCourseIds = new Set(selectedCourses.map((c) => c.id));
const availableCourses = courses.filter(
(c) => !takenCourseIds.has(c.id) && !selectedCourseIds.has(c.id),
);
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses); const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
const availableLecturers = course const selectedDepartmentIds = new Set(
? lecturers.filter((l) => selectedCourses.map((c) => c.department_id),
l.departments.some( );
(department) => department.id === course.department_id, const availableLecturers =
), selectedCourses.length > 0
) ? lecturers.filter((l) =>
: lecturers; [...selectedDepartmentIds].every((departmentId) =>
l.departments.some(
(department) => department.id === departmentId,
),
),
)
: lecturers;
function reset() { function reset() {
setAcademicTermId(''); setAcademicTermId('');
setCourse(null); setSelectedCourses([]);
setLecturer(null); setLecturer(null);
} }
@ -351,7 +365,7 @@ function CreateForm({
value={academicTermId} value={academicTermId}
onValueChange={(value) => { onValueChange={(value) => {
setAcademicTermId(value); setAcademicTermId(value);
setCourse(null); setSelectedCourses([]);
setLecturer(null); setLecturer(null);
}} }}
> >
@ -376,26 +390,43 @@ function CreateForm({
Mata Kuliah{' '} Mata Kuliah{' '}
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<input {selectedCourses.map((c) => (
type="hidden" <input
name="course_id" key={c.id}
value={course?.id ?? ''} type="hidden"
/> name="course_ids[]"
value={c.id}
/>
))}
<Combobox <Combobox
items={courseGroups} items={courseGroups}
value={course} multiple
value={selectedCourses}
onValueChange={(value) => { onValueChange={(value) => {
setCourse(value); setSelectedCourses(value);
setLecturer(null); setLecturer(null);
}} }}
itemToStringLabel={(c) => `${c.code} - ${c.name}`} itemToStringLabel={(c) => `${c.code} - ${c.name}`}
isItemEqualToValue={(a, b) => a.id === b.id} isItemEqualToValue={(a, b) => a.id === b.id}
> >
<ComboboxInput <ComboboxChips ref={courseAnchor}>
placeholder="Pilih mata kuliah" {selectedCourses.map((c) => (
className="w-full" <ComboboxChip
/> key={c.id}
<ComboboxContent> aria-label={`${c.code} - ${c.name}`}
>
{c.code}
</ComboboxChip>
))}
<ComboboxChipsInput
placeholder={
selectedCourses.length === 0
? 'Pilih mata kuliah (bisa lebih dari satu)'
: ''
}
/>
</ComboboxChips>
<ComboboxContent anchor={courseAnchor}>
<ComboboxEmpty> <ComboboxEmpty>
Mata kuliah tidak ditemukan. Mata kuliah tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
@ -423,7 +454,7 @@ function CreateForm({
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
<InputError message={errors.course_id} /> <InputError message={errors.course_ids} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
@ -443,12 +474,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={!course} disabled={selectedCourses.length === 0}
> >
<ComboboxInput <ComboboxInput
disabled={!course} disabled={selectedCourses.length === 0}
placeholder={ placeholder={
course selectedCourses.length > 0
? 'Pilih dosen pengampu' ? 'Pilih dosen pengampu'
: 'Pilih mata kuliah terlebih dahulu' : 'Pilih mata kuliah terlebih dahulu'
} }