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\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\CourseClassRequest;
|
use App\Http\Requests\Admin\Manage\CourseClassRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\DuplicateCourseClassesRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
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();
|
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 App\Models\CourseClass;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CourseClassService
|
class CourseClassService
|
||||||
{
|
{
|
||||||
@ -57,4 +58,47 @@ public function delete(CourseClass $courseClass): bool
|
|||||||
{
|
{
|
||||||
return $courseClass->delete();
|
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 { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Copy, Plus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
@ -38,6 +38,7 @@ import { useServerTable } from '@/hooks/use-server-table';
|
|||||||
import {
|
import {
|
||||||
index as courseClassIndex,
|
index as courseClassIndex,
|
||||||
destroy,
|
destroy,
|
||||||
|
duplicate,
|
||||||
store,
|
store,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/manage/course-classes';
|
} from '@/routes/admin/manage/course-classes';
|
||||||
@ -119,6 +120,7 @@ export default function CourseClassIndex({
|
|||||||
filters,
|
filters,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<CourseClass | null>(null);
|
const [editing, setEditing] = useState<CourseClass | null>(null);
|
||||||
const [deleting, setDeleting] = useState<CourseClass | null>(null);
|
const [deleting, setDeleting] = useState<CourseClass | null>(null);
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
@ -214,15 +216,24 @@ export default function CourseClassIndex({
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
canCreate && (
|
canCreate && (
|
||||||
<Button asChild>
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setDuplicateOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
Tambah
|
Duplikat dari Periode
|
||||||
</button>
|
</Button>
|
||||||
</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}
|
courseClassAssignments={courseClassAssignments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DuplicateForm
|
||||||
|
open={duplicateOpen}
|
||||||
|
onOpenChange={setDuplicateOpen}
|
||||||
|
academicTerms={academicTerms}
|
||||||
|
/>
|
||||||
|
|
||||||
<EditForm
|
<EditForm
|
||||||
key={editing?.id}
|
key={editing?.id}
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
@ -755,3 +772,112 @@ function EditForm({
|
|||||||
</FormDialog>
|
</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(['update'], 'permission:update-course-classes')
|
||||||
->middlewareFor(['destroy'], 'permission:delete-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::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::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');
|
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy')->middleware('permission:delete-course-class-enrollments');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user