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
{
$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');
}

View File

@ -4,6 +4,7 @@
use App\Enums\ClassMethod;
use App\Models\Course;
use App\Models\Lecturer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -16,10 +17,51 @@ public function authorize(): bool
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;
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' => [
'required',
'integer',

View File

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

View File

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