feat: add duplicate course classes functionality with validation and UI integration

This commit is contained in:
Yoga Pangestu 2026-09-02 17:39:58 +07:00
parent 0d27a9e706
commit c1cee7cd45
5 changed files with 236 additions and 9 deletions

View File

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

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class DuplicateCourseClassesRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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.');
}
});
}
}

View File

@ -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];
});
}
}

View File

@ -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<CourseClass | null>(null);
const [deleting, setDeleting] = useState<CourseClass | null>(null);
const { hasPermission } = usePermissions();
@ -214,15 +216,24 @@ export default function CourseClassIndex({
}
actions={
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDuplicateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
<Copy className="h-4 w-4" />
Duplikat dari Periode
</Button>
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
</div>
)
}
/>
@ -236,6 +247,12 @@ export default function CourseClassIndex({
courseClassAssignments={courseClassAssignments}
/>
<DuplicateForm
open={duplicateOpen}
onOpenChange={setDuplicateOpen}
academicTerms={academicTerms}
/>
<EditForm
key={editing?.id}
open={editing !== null}
@ -755,3 +772,112 @@ function EditForm({
</FormDialog>
);
}
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 (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Duplikat Kelas Mata Kuliah"
action={duplicate()}
resetOnSuccess
submitLabel="Duplikat"
submittingLabel="Menduplikasi..."
onSuccess={() => {
onOpenChange(false);
reset();
}}
>
{({ errors }) => (
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
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.
</p>
<div className="grid gap-2">
<Label>
Dari Periode{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="source_academic_term_id"
value={sourceTermId}
/>
<Select
value={sourceTermId}
onValueChange={setSourceTermId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode sumber" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError
message={errors.source_academic_term_id}
/>
</div>
<div className="grid gap-2">
<Label>
Ke Periode{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="target_academic_term_id"
value={targetTermId}
/>
<Select
value={targetTermId}
onValueChange={setTargetTermId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode tujuan" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError
message={errors.target_academic_term_id}
/>
</div>
</div>
)}
</FormDialog>
);
}

View File

@ -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');