feat: add duplicate course classes functionality with validation and UI integration
This commit is contained in:
parent
0d27a9e706
commit
c1cee7cd45
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user