From c1cee7cd4510745cebc8509c3160002d965deec2 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 2 Sep 2026 17:39:58 +0700 Subject: [PATCH] feat: add duplicate course classes functionality with validation and UI integration --- .../Admin/Manage/CourseClassController.php | 21 +++ .../Manage/DuplicateCourseClassesRequest.php | 32 ++++ .../Admin/Manage/CourseClassService.php | 44 ++++++ .../admin/manage/course-classes/index.tsx | 144 ++++++++++++++++-- routes/admin.php | 4 + 5 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 app/Http/Requests/Admin/Manage/DuplicateCourseClassesRequest.php diff --git a/app/Http/Controllers/Admin/Manage/CourseClassController.php b/app/Http/Controllers/Admin/Manage/CourseClassController.php index f1f32d9..ca0abfb 100644 --- a/app/Http/Controllers/Admin/Manage/CourseClassController.php +++ b/app/Http/Controllers/Admin/Manage/CourseClassController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Manage\CourseClassRequest; +use App\Http\Requests\Admin\Manage\DuplicateCourseClassesRequest; use App\Http\Requests\PaginatedRequest; use App\Models\CourseClass; use App\Services\Admin\Manage\CourseClassService; @@ -68,4 +69,24 @@ public function destroy(CourseClass $courseClass): RedirectResponse return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil dihapus.'])->back(); } + + public function duplicate(DuplicateCourseClassesRequest $request): RedirectResponse + { + $result = $this->service->duplicateFromTerm( + $request->validated('source_academic_term_id'), + $request->validated('target_academic_term_id'), + ); + + $message = $result['created'] > 0 + ? "{$result['created']} kelas mata kuliah berhasil diduplikasi." + : 'Tidak ada kelas mata kuliah baru yang diduplikasi.'; + + if ($result['skipped'] > 0) { + $message .= " {$result['skipped']} dilewati karena sudah ada di periode tujuan."; + } + + Inertia::flash('toast', ['type' => 'success', 'message' => $message]); + + return to_route('admin.manage.course-classes.index'); + } } diff --git a/app/Http/Requests/Admin/Manage/DuplicateCourseClassesRequest.php b/app/Http/Requests/Admin/Manage/DuplicateCourseClassesRequest.php new file mode 100644 index 0000000..e2ea5d9 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/DuplicateCourseClassesRequest.php @@ -0,0 +1,32 @@ +user()->can('create-course-classes'); + } + + public function rules(): array + { + return [ + 'source_academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], + 'target_academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + if ($this->input('source_academic_term_id') === $this->input('target_academic_term_id')) { + $validator->errors()->add('target_academic_term_id', 'Periode tujuan harus berbeda dari periode sumber.'); + } + }); + } +} diff --git a/app/Services/Admin/Manage/CourseClassService.php b/app/Services/Admin/Manage/CourseClassService.php index b6417e9..88a6f22 100644 --- a/app/Services/Admin/Manage/CourseClassService.php +++ b/app/Services/Admin/Manage/CourseClassService.php @@ -5,6 +5,7 @@ use App\Models\CourseClass; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; class CourseClassService { @@ -57,4 +58,47 @@ public function delete(CourseClass $courseClass): bool { return $courseClass->delete(); } + + /** + * Copies every course class from one academic term into another, + * skipping courses that already have a class in the target term. + * + * @return array{created: int, skipped: int} + */ + public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademicTermId): array + { + return DB::transaction(function () use ($sourceAcademicTermId, $targetAcademicTermId) { + $sourceClasses = CourseClass::query() + ->where('academic_term_id', $sourceAcademicTermId) + ->get(['course_id', 'lecturer_id', 'method']); + + $existingCourseIds = CourseClass::query() + ->where('academic_term_id', $targetAcademicTermId) + ->pluck('course_id') + ->all(); + + $created = 0; + $skipped = 0; + + foreach ($sourceClasses as $sourceClass) { + if (in_array($sourceClass->course_id, $existingCourseIds, true)) { + $skipped++; + + continue; + } + + CourseClass::create([ + 'course_id' => $sourceClass->course_id, + 'lecturer_id' => $sourceClass->lecturer_id, + 'academic_term_id' => $targetAcademicTermId, + 'method' => $sourceClass->method, + ]); + + $existingCourseIds[] = $sourceClass->course_id; + $created++; + } + + return ['created' => $created, 'skipped' => $skipped]; + }); + } } diff --git a/resources/js/pages/admin/manage/course-classes/index.tsx b/resources/js/pages/admin/manage/course-classes/index.tsx index b972bd0..40ec0e3 100644 --- a/resources/js/pages/admin/manage/course-classes/index.tsx +++ b/resources/js/pages/admin/manage/course-classes/index.tsx @@ -1,5 +1,5 @@ import { Head, router } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; +import { Copy, Plus } from 'lucide-react'; import { useState } from 'react'; import type { PaginationState } from '@/components/data-table'; import { DataTable } from '@/components/data-table'; @@ -38,6 +38,7 @@ import { useServerTable } from '@/hooks/use-server-table'; import { index as courseClassIndex, destroy, + duplicate, store, update, } from '@/routes/admin/manage/course-classes'; @@ -119,6 +120,7 @@ export default function CourseClassIndex({ filters, }: Props) { const [createOpen, setCreateOpen] = useState(false); + const [duplicateOpen, setDuplicateOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const { hasPermission } = usePermissions(); @@ -214,15 +216,24 @@ export default function CourseClassIndex({ } actions={ canCreate && ( - - + + Duplikat dari Periode + + + + ) } /> @@ -236,6 +247,12 @@ export default function CourseClassIndex({ courseClassAssignments={courseClassAssignments} /> + + ); } + +function DuplicateForm({ + open, + onOpenChange, + academicTerms, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + academicTerms: AcademicTerm[]; +}) { + const [sourceTermId, setSourceTermId] = useState(''); + const [targetTermId, setTargetTermId] = useState(''); + + function reset() { + setSourceTermId(''); + setTargetTermId(''); + } + + return ( + { + onOpenChange(false); + reset(); + }} + > + {({ errors }) => ( +
+

+ Menyalin semua kelas mata kuliah (mata kuliah, dosen + pengampu, metode) dari periode sumber ke periode + tujuan. Mata kuliah yang sudah punya kelas di periode + tujuan akan dilewati. +

+
+ + + + +
+
+ + + + +
+
+ )} +
+ ); +} diff --git a/routes/admin.php b/routes/admin.php index 3972be3..9f6fde3 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -115,6 +115,10 @@ ->middlewareFor(['update'], 'permission:update-course-classes') ->middlewareFor(['destroy'], 'permission:delete-course-classes'); + Route::post('course-classes/duplicate', [CourseClassController::class, 'duplicate']) + ->name('course-classes.duplicate') + ->middleware('permission:create-course-classes'); + Route::prefix('course-classes/{course_class}/enrollments')->name('course-classes.enrollments.')->group(function () { Route::get('/', [ClassEnrollmentController::class, 'index'])->name('index')->middleware('permission:view-course-class-enrollments'); Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy')->middleware('permission:delete-course-class-enrollments');