feat: add course and class enrollment management
- Implemented ClassEnrollmentIndex component for managing class enrollments, including adding and removing students. - Created CourseClassIndex component for managing course classes with CRUD functionality. - Developed columns for course management in createCourseColumns function. - Added CourseIndex component for managing courses with CRUD operations. - Introduced types for class enrollment and course class to enhance type safety. - Updated routes to include endpoints for managing courses, course classes, and enrollments.
This commit is contained in:
parent
8c81e990b8
commit
015cbc229b
19
app/Enums/ClassMethod.php
Normal file
19
app/Enums/ClassMethod.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ClassMethod: string
|
||||
{
|
||||
case Online = 'online';
|
||||
case Offline = 'offline';
|
||||
case Hybrid = 'hybrid';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Online => 'Daring',
|
||||
self::Offline => 'Luring',
|
||||
self::Hybrid => 'Hybrid',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\ClassEnrollmentRequest;
|
||||
use App\Models\ClassEnrollment;
|
||||
use App\Models\CourseClass;
|
||||
use App\Services\Admin\Manage\ClassEnrollmentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ClassEnrollmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClassEnrollmentService $service,
|
||||
) {}
|
||||
|
||||
public function index(CourseClass $courseClass): Response
|
||||
{
|
||||
$courseClass->load(['course', 'lecturer.user.profile', 'academicTerm']);
|
||||
|
||||
return Inertia::render('admin/manage/course-classes/enrollments', [
|
||||
'courseClass' => $courseClass,
|
||||
'enrollments' => $this->service->forClass($courseClass),
|
||||
'availableStudents' => $this->service->availableStudents($courseClass),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ClassEnrollmentRequest $request, CourseClass $courseClass): RedirectResponse
|
||||
{
|
||||
$this->service->enrollMany($courseClass, $request->validated('student_ids'));
|
||||
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil ditambahkan ke kelas.'])->back();
|
||||
}
|
||||
|
||||
public function destroy(CourseClass $courseClass, ClassEnrollment $enrollment): RedirectResponse
|
||||
{
|
||||
$this->service->unenroll($enrollment);
|
||||
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil dikeluarkan dari kelas.'])->back();
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Admin/Manage/CourseClassController.php
Normal file
60
app/Http/Controllers/Admin/Manage/CourseClassController.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CourseClassRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\CourseClass;
|
||||
use App\Services\Admin\Manage\CourseClassService;
|
||||
use App\Services\Admin\Manage\CourseService;
|
||||
use App\Services\Admin\Master\AcademicTermService;
|
||||
use App\Services\Admin\Users\LecturerService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CourseClassController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseClassService $service,
|
||||
private readonly CourseService $courseService,
|
||||
private readonly LecturerService $lecturerService,
|
||||
private readonly AcademicTermService $academicTermService,
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/course-classes/index', [
|
||||
'courseClasses' => $this->service->paginated(...$request->validatedWithDefaults()),
|
||||
'courses' => $this->courseService->getAll(),
|
||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||
'academicTerms' => $this->academicTermService->getAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CourseClassRequest $request): RedirectResponse
|
||||
{
|
||||
$this->service->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil ditambahkan.']);
|
||||
|
||||
return to_route('admin.manage.course-classes.index');
|
||||
}
|
||||
|
||||
public function update(CourseClassRequest $request, CourseClass $courseClass): RedirectResponse
|
||||
{
|
||||
$this->service->update($courseClass, $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil diperbarui.']);
|
||||
|
||||
return to_route('admin.manage.course-classes.index');
|
||||
}
|
||||
|
||||
public function destroy(CourseClass $courseClass): RedirectResponse
|
||||
{
|
||||
$this->service->delete($courseClass);
|
||||
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kelas mata kuliah berhasil dihapus.'])->back();
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Admin/Manage/CourseController.php
Normal file
54
app/Http/Controllers/Admin/Manage/CourseController.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CourseRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Course;
|
||||
use App\Services\Admin\Manage\CourseService;
|
||||
use App\Services\Admin\Users\DepartmentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CourseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseService $service,
|
||||
private readonly DepartmentService $departmentService,
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/courses/index', [
|
||||
'courses' => $this->service->paginated(...$request->validatedWithDefaults()),
|
||||
'departments' => $this->departmentService->getAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CourseRequest $request): RedirectResponse
|
||||
{
|
||||
$this->service->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Mata kuliah berhasil ditambahkan.']);
|
||||
|
||||
return to_route('admin.manage.courses.index');
|
||||
}
|
||||
|
||||
public function update(CourseRequest $request, Course $course): RedirectResponse
|
||||
{
|
||||
$this->service->update($course, $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Mata kuliah berhasil diperbarui.']);
|
||||
|
||||
return to_route('admin.manage.courses.index');
|
||||
}
|
||||
|
||||
public function destroy(Course $course): RedirectResponse
|
||||
{
|
||||
$this->service->delete($course);
|
||||
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mata kuliah berhasil dihapus.'])->back();
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Admin/Manage/ClassEnrollmentRequest.php
Normal file
29
app/Http/Requests/Admin/Manage/ClassEnrollmentRequest.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ClassEnrollmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$courseClass = $this->route('course_class');
|
||||
$departmentId = $courseClass->course->department_id;
|
||||
|
||||
return [
|
||||
'student_ids' => ['required', 'array', 'min:1'],
|
||||
'student_ids.*' => [
|
||||
'integer',
|
||||
Rule::exists('students', 'id')->where('department_id', $departmentId),
|
||||
Rule::unique('class_enrollments', 'student_id')->where('course_class_id', $courseClass->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/Admin/Manage/CourseClassRequest.php
Normal file
33
app/Http/Requests/Admin/Manage/CourseClassRequest.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\ClassMethod;
|
||||
use App\Models\Course;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CourseClassRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$departmentId = Course::find($this->input('course_id'))?->department_id;
|
||||
|
||||
return [
|
||||
'course_id' => ['required', 'integer', Rule::exists('courses', 'id')],
|
||||
'lecturer_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('lecturers', 'id')->where('department_id', $departmentId),
|
||||
],
|
||||
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
||||
'class_name' => ['nullable', 'string', 'max:50'],
|
||||
'method' => ['nullable', 'string', Rule::in(array_column(ClassMethod::cases(), 'value'))],
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Admin/Manage/CourseRequest.php
Normal file
30
app/Http/Requests/Admin/Manage/CourseRequest.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CourseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:20',
|
||||
Rule::unique('courses')->ignore($this->route('course')),
|
||||
],
|
||||
'name' => ['required', 'string', 'max:150'],
|
||||
'credits' => ['required', 'integer', 'min:1', 'max:10'],
|
||||
'department_id' => ['required', 'integer', Rule::exists('departments', 'id')],
|
||||
'semester_number' => ['nullable', 'integer', 'min:1', 'max:14'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Models/ClassEnrollment.php
Normal file
31
app/Models/ClassEnrollment.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class ClassEnrollment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enrolled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function courseClass(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CourseClass::class);
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/Course.php
Normal file
26
app/Models/Course.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class Course extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function classes(): HasMany
|
||||
{
|
||||
return $this->hasMany(CourseClass::class);
|
||||
}
|
||||
}
|
||||
44
app/Models/CourseClass.php
Normal file
44
app/Models/CourseClass.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ClassMethod;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class CourseClass extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'method' => ClassMethod::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function course(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Course::class);
|
||||
}
|
||||
|
||||
public function lecturer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lecturer::class);
|
||||
}
|
||||
|
||||
public function academicTerm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicTerm::class);
|
||||
}
|
||||
|
||||
public function enrollments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClassEnrollment::class);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
@ -35,4 +36,9 @@ public function academicAdvisor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lecturer::class, 'academic_advisor_id');
|
||||
}
|
||||
|
||||
public function enrollments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClassEnrollment::class);
|
||||
}
|
||||
}
|
||||
|
||||
43
app/Services/Admin/Manage/ClassEnrollmentService.php
Normal file
43
app/Services/Admin/Manage/ClassEnrollmentService.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Models\ClassEnrollment;
|
||||
use App\Models\CourseClass;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ClassEnrollmentService
|
||||
{
|
||||
public function forClass(CourseClass $courseClass): Collection
|
||||
{
|
||||
return $courseClass->enrollments()
|
||||
->with(['student.user.profile', 'student.department'])
|
||||
->latest('enrolled_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function availableStudents(CourseClass $courseClass): Collection
|
||||
{
|
||||
return Student::query()
|
||||
->where('department_id', $courseClass->course->department_id)
|
||||
->whereDoesntHave('enrollments', fn ($q) => $q->where('course_class_id', $courseClass->id))
|
||||
->with(['user.profile', 'department'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function enrollMany(CourseClass $courseClass, array $studentIds): void
|
||||
{
|
||||
foreach ($studentIds as $studentId) {
|
||||
$courseClass->enrollments()->firstOrCreate(
|
||||
['student_id' => $studentId],
|
||||
['enrolled_at' => now()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function unenroll(ClassEnrollment $enrollment): bool
|
||||
{
|
||||
return $enrollment->delete();
|
||||
}
|
||||
}
|
||||
43
app/Services/Admin/Manage/CourseClassService.php
Normal file
43
app/Services/Admin/Manage/CourseClassService.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Models\CourseClass;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class CourseClassService
|
||||
{
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return CourseClass::query()
|
||||
->select(['id', 'course_id', 'lecturer_id', 'academic_term_id', 'class_name', 'method'])
|
||||
->withCount('enrollments')
|
||||
->with(['course:id,code,name,department_id', 'lecturer.user.profile', 'academicTerm:id,name,semester,start_date,end_date'])
|
||||
->when($search, fn ($q) => $q->where('class_name', 'like', "%{$search}%")
|
||||
->orWhereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")))
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function create(array $data): CourseClass
|
||||
{
|
||||
return CourseClass::create($data);
|
||||
}
|
||||
|
||||
public function update(CourseClass $courseClass, array $data): CourseClass
|
||||
{
|
||||
$courseClass->course_id = $data['course_id'];
|
||||
$courseClass->lecturer_id = $data['lecturer_id'];
|
||||
$courseClass->academic_term_id = $data['academic_term_id'];
|
||||
$courseClass->class_name = $data['class_name'] ?? null;
|
||||
$courseClass->method = $data['method'] ?? null;
|
||||
$courseClass->update();
|
||||
|
||||
return $courseClass;
|
||||
}
|
||||
|
||||
public function delete(CourseClass $courseClass): bool
|
||||
{
|
||||
return $courseClass->delete();
|
||||
}
|
||||
}
|
||||
47
app/Services/Admin/Manage/CourseService.php
Normal file
47
app/Services/Admin/Manage/CourseService.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Models\Course;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class CourseService
|
||||
{
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Course::select(['id', 'code', 'name', 'department_id'])->get();
|
||||
}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return Course::query()
|
||||
->select(['id', 'code', 'name', 'credits', 'department_id', 'semester_number'])
|
||||
->with('department:id,name')
|
||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function create(array $data): Course
|
||||
{
|
||||
return Course::create($data);
|
||||
}
|
||||
|
||||
public function update(Course $course, array $data): Course
|
||||
{
|
||||
$course->code = $data['code'];
|
||||
$course->name = $data['name'];
|
||||
$course->credits = $data['credits'];
|
||||
$course->department_id = $data['department_id'];
|
||||
$course->semester_number = $data['semester_number'] ?? null;
|
||||
$course->update();
|
||||
|
||||
return $course;
|
||||
}
|
||||
|
||||
public function delete(Course $course): bool
|
||||
{
|
||||
return $course->delete();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('courses', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code', 20)->unique();
|
||||
$table->string('name', 150);
|
||||
$table->integer('credits');
|
||||
$table->foreignId('department_id')->constrained()->cascadeOnDelete();
|
||||
$table->integer('semester_number')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('courses');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ClassMethod;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('course_classes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('lecturer_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('class_name', 50)->nullable();
|
||||
$table->enum('method', array_values(ClassMethod::cases()))->nullable()->default(ClassMethod::Hybrid->value);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('course_classes');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('class_enrollments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('course_class_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamp('enrolled_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['course_class_id', 'student_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('class_enrollments');
|
||||
}
|
||||
};
|
||||
29
database/seeders/ClassEnrollmentSeeder.php
Normal file
29
database/seeders/ClassEnrollmentSeeder.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\ClassEnrollment;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ClassEnrollmentSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
ClassEnrollment::insert([
|
||||
[
|
||||
'course_class_id' => 1,
|
||||
'student_id' => 1,
|
||||
'enrolled_at' => '2026-02-02 08:00:00',
|
||||
'created_at' => '2026-02-02 08:00:00',
|
||||
'updated_at' => '2026-02-02 08:00:00',
|
||||
],
|
||||
[
|
||||
'course_class_id' => 2,
|
||||
'student_id' => 2,
|
||||
'enrolled_at' => '2026-02-02 08:15:00',
|
||||
'created_at' => '2026-02-02 08:15:00',
|
||||
'updated_at' => '2026-02-02 08:15:00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
database/seeders/CourseClassSeeder.php
Normal file
33
database/seeders/CourseClassSeeder.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CourseClass;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CourseClassSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
CourseClass::insert([
|
||||
[
|
||||
'course_id' => 1,
|
||||
'lecturer_id' => 1,
|
||||
'academic_term_id' => 2,
|
||||
'class_name' => 'SI-5A',
|
||||
'method' => 'hybrid',
|
||||
'created_at' => '2026-01-10 08:00:00',
|
||||
'updated_at' => '2026-01-10 08:00:00',
|
||||
],
|
||||
[
|
||||
'course_id' => 2,
|
||||
'lecturer_id' => 2,
|
||||
'academic_term_id' => 2,
|
||||
'class_name' => 'TI-3A',
|
||||
'method' => 'offline',
|
||||
'created_at' => '2026-01-10 08:00:00',
|
||||
'updated_at' => '2026-01-10 08:00:00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
database/seeders/CourseSeeder.php
Normal file
33
database/seeders/CourseSeeder.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Course;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CourseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
Course::insert([
|
||||
[
|
||||
'code' => 'SI301',
|
||||
'name' => 'Pemrograman Web',
|
||||
'credits' => 3,
|
||||
'department_id' => 1,
|
||||
'semester_number' => 5,
|
||||
'created_at' => '2024-02-01 08:00:00',
|
||||
'updated_at' => '2024-02-01 08:00:00',
|
||||
],
|
||||
[
|
||||
'code' => 'TI201',
|
||||
'name' => 'Manajemen Kualitas',
|
||||
'credits' => 3,
|
||||
'department_id' => 2,
|
||||
'semester_number' => 3,
|
||||
'created_at' => '2024-02-01 08:00:00',
|
||||
'updated_at' => '2024-02-01 08:00:00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,9 @@ public function run(): void
|
||||
UserSeeder::class,
|
||||
AcademicTermSeeder::class,
|
||||
DepartmentLeadershipSeeder::class,
|
||||
CourseSeeder::class,
|
||||
CourseClassSeeder::class,
|
||||
ClassEnrollmentSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,12 +19,22 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes';
|
||||
import { index as coursesRoute } from '@/routes/admin/manage/courses';
|
||||
import { index as academicTerm } from '@/routes/admin/master/academic-terms';
|
||||
import { index as departmentsRoute } from '@/routes/admin/master/departments';
|
||||
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
||||
import { index as studentsRoute } from '@/routes/admin/users/students';
|
||||
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
|
||||
import { Building2, Calendar, GraduationCap, User, Users } from 'lucide-react';
|
||||
import {
|
||||
BookOpen,
|
||||
Building2,
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
School,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
const data: {
|
||||
navMain: (NavGroup | NavItem)[];
|
||||
@ -51,6 +61,21 @@ const data: {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Kelola',
|
||||
items: [
|
||||
{
|
||||
name: 'Mata Kuliah',
|
||||
url: coursesRoute.url(),
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
name: 'Kelas Mata Kuliah',
|
||||
url: courseClassesRoute.url(),
|
||||
icon: School,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Pengguna',
|
||||
items: [
|
||||
|
||||
124
resources/js/pages/admin/manage/course-classes/columns.tsx
Normal file
124
resources/js/pages/admin/manage/course-classes/columns.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2, Users } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { index as enrollmentsIndex } from '@/routes/admin/manage/course-classes/enrollments';
|
||||
import { ClassMethodLabels } from '@/types/course-class';
|
||||
import type { CourseClass } from '@/types/course-class';
|
||||
|
||||
export type { CourseClass } from '@/types/course-class';
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (courseClass: CourseClass) => void;
|
||||
handleDeleteClick: (courseClass: CourseClass) => void;
|
||||
};
|
||||
|
||||
export function createCourseClassColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<CourseClass>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'class_name',
|
||||
header: () => <span>Kelas</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{(row.getValue('class_name') as string | null) ?? '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'course.name',
|
||||
header: () => <span>Mata Kuliah</span>,
|
||||
cell: ({ row }) => {
|
||||
const course = row.original.course;
|
||||
|
||||
return course ? `${course.code} - ${course.name}` : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'lecturer.user.profile.full_name',
|
||||
header: () => <span>Dosen Pengampu</span>,
|
||||
cell: ({ row }) =>
|
||||
row.original.lecturer?.user?.profile?.full_name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'academic_term.name',
|
||||
header: () => <span>Periode</span>,
|
||||
cell: ({ row }) => row.original.academic_term?.name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'method',
|
||||
header: () => <span className="block text-center">Metode</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const method = row.getValue('method') as
|
||||
keyof typeof ClassMethodLabels | null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{method ? (
|
||||
<Badge variant="secondary">
|
||||
{ClassMethodLabels[method]}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'enrollments_count',
|
||||
header: () => <span className="block text-center">Peserta</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="text-center">
|
||||
{row.original.enrollments_count}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[130px] text-center',
|
||||
headerClassName: 'w-[130px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const courseClass = row.original;
|
||||
|
||||
return (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Peserta',
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
href: enrollmentsIndex.url(courseClass.id),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(courseClass),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(courseClass),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
301
resources/js/pages/admin/manage/course-classes/enrollments.tsx
Normal file
301
resources/js/pages/admin/manage/course-classes/enrollments.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { format } from 'date-fns';
|
||||
import { Trash2, UserPlus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { index as courseClassIndex } from '@/routes/admin/manage/course-classes';
|
||||
import {
|
||||
destroy,
|
||||
store,
|
||||
} from '@/routes/admin/manage/course-classes/enrollments';
|
||||
import type {
|
||||
ClassEnrollment,
|
||||
EnrollmentStudent,
|
||||
} from '@/types/class-enrollment';
|
||||
import type { CourseClass } from '@/types/course-class';
|
||||
import { ClassMethodLabels } from '@/types/course-class';
|
||||
|
||||
type Props = {
|
||||
courseClass: CourseClass;
|
||||
enrollments: ClassEnrollment[];
|
||||
availableStudents: EnrollmentStudent[];
|
||||
};
|
||||
|
||||
export default function ClassEnrollmentIndex({
|
||||
courseClass,
|
||||
enrollments,
|
||||
availableStudents,
|
||||
}: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<ClassEnrollment | null>(null);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy([courseClass.id, deleting.id]), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ClassEnrollment>[] = [
|
||||
{
|
||||
accessorKey: 'student.student_number',
|
||||
header: () => <span>NIM</span>,
|
||||
cell: ({ row }) => row.original.student?.student_number ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'student.user.profile.full_name',
|
||||
header: () => <span>Nama Mahasiswa</span>,
|
||||
cell: ({ row }) =>
|
||||
row.original.student?.user?.profile?.full_name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'student.department.name',
|
||||
header: () => <span>Jurusan</span>,
|
||||
cell: ({ row }) => row.original.student?.department?.name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'enrolled_at',
|
||||
header: () => <span>Tanggal Daftar</span>,
|
||||
cell: ({ row }) => {
|
||||
const enrolledAt = row.getValue('enrolled_at') as string | null;
|
||||
|
||||
return enrolledAt
|
||||
? format(new Date(enrolledAt), 'd MMM yyyy')
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[80px] text-center',
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Keluarkan',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => setDeleting(row.original),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Peserta Kelas" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Peserta Kelas"
|
||||
actions={
|
||||
<Button variant="outline" asChild>
|
||||
<a href={courseClassIndex.url()}>Kembali</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{courseClass.class_name ?? courseClass.course?.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Mata Kuliah: {courseClass.course?.code} -{' '}
|
||||
{courseClass.course?.name}
|
||||
</p>
|
||||
<p>
|
||||
Dosen Pengampu:{' '}
|
||||
{courseClass.lecturer?.user?.profile?.full_name ??
|
||||
'-'}
|
||||
</p>
|
||||
<p>Periode: {courseClass.academic_term?.name}</p>
|
||||
<p>
|
||||
Metode:{' '}
|
||||
{courseClass.method
|
||||
? ClassMethodLabels[courseClass.method]
|
||||
: '-'}
|
||||
</p>
|
||||
<p>Jumlah Peserta: {enrollments.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Tambah Mahasiswa
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={enrollments}
|
||||
emptyText="Belum ada mahasiswa yang terdaftar di kelas ini."
|
||||
/>
|
||||
|
||||
<EnrollForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
courseClassId={courseClass.id}
|
||||
availableStudents={availableStudents}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Keluarkan Mahasiswa"
|
||||
description={(enrollment) =>
|
||||
`Apakah Anda yakin ingin mengeluarkan "${enrollment.student?.user?.profile?.full_name ?? 'mahasiswa ini'}" dari kelas ini?`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EnrollForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
courseClassId,
|
||||
availableStudents,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
courseClassId: number;
|
||||
availableStudents: EnrollmentStudent[];
|
||||
}) {
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
const allSelected =
|
||||
availableStudents.length > 0 &&
|
||||
selectedIds.length === availableStudents.length;
|
||||
|
||||
function toggleAll() {
|
||||
setSelectedIds(
|
||||
allSelected ? [] : availableStudents.map((student) => student.id),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleOne(id: number) {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id)
|
||||
? prev.filter((selectedId) => selectedId !== id)
|
||||
: [...prev, id],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Tambah Mahasiswa"
|
||||
action={store(courseClassId)}
|
||||
resetOnSuccess
|
||||
submitDisabled={selectedIds.length === 0}
|
||||
submitLabel={
|
||||
selectedIds.length > 0
|
||||
? `Tambahkan (${selectedIds.length})`
|
||||
: 'Tambahkan'
|
||||
}
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Mahasiswa{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{availableStudents.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="select-all-students"
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="select-all-students"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
Pilih Semua
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedIds.map((id) => (
|
||||
<input
|
||||
key={id}
|
||||
type="hidden"
|
||||
name="student_ids[]"
|
||||
value={id}
|
||||
/>
|
||||
))}
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border">
|
||||
{availableStudents.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
Tidak ada mahasiswa dari jurusan yang sama
|
||||
untuk didaftarkan.
|
||||
</p>
|
||||
) : (
|
||||
availableStudents.map((student) => (
|
||||
<label
|
||||
key={student.id}
|
||||
htmlFor={`student-${student.id}`}
|
||||
className="flex items-center gap-2 border-b px-3 py-2 last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`student-${student.id}`}
|
||||
checked={selectedIds.includes(
|
||||
student.id,
|
||||
)}
|
||||
onCheckedChange={() =>
|
||||
toggleOne(student.id)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{student.user?.profile?.full_name ??
|
||||
'N/A'}{' '}
|
||||
- {student.student_number}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<InputError message={errors.student_ids} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
524
resources/js/pages/admin/manage/course-classes/index.tsx
Normal file
524
resources/js/pages/admin/manage/course-classes/index.tsx
Normal file
@ -0,0 +1,524 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
index as courseClassIndex,
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/manage/course-classes';
|
||||
import { ClassMethodLabels, ClassMethods } from '@/types/course-class';
|
||||
import type { CourseClass } from '@/types/course-class';
|
||||
import { createCourseClassColumns } from './columns';
|
||||
|
||||
type Course = { id: number; code: string; name: string; department_id: number };
|
||||
type Lecturer = {
|
||||
id: number;
|
||||
lecturer_number: string;
|
||||
department_id: number;
|
||||
user: { profile: { full_name: string } | null } | null;
|
||||
};
|
||||
type AcademicTerm = { id: number; name: string; semester: string };
|
||||
|
||||
type Props = {
|
||||
courseClasses: {
|
||||
data: CourseClass[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
courses: Course[];
|
||||
lecturers: Lecturer[];
|
||||
academicTerms: AcademicTerm[];
|
||||
highlight?: number;
|
||||
};
|
||||
|
||||
export default function CourseClassIndex({
|
||||
courseClasses,
|
||||
courses,
|
||||
lecturers,
|
||||
academicTerms,
|
||||
highlight,
|
||||
}: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<CourseClass | null>(null);
|
||||
const [deleting, setDeleting] = useState<CourseClass | null>(null);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: courseClasses.current_page,
|
||||
last_page: courseClasses.last_page,
|
||||
per_page: courseClasses.per_page,
|
||||
total: courseClasses.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => courseClassIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createCourseClassColumns({
|
||||
handleEdit: (courseClass) => setEditing(courseClass),
|
||||
handleDeleteClick: (courseClass) => setDeleting(courseClass),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Kelas Mata Kuliah" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Kelas Mata Kuliah"
|
||||
description={
|
||||
highlight && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Menampilkan kelas mata kuliah dari notifikasi.
|
||||
<button
|
||||
onClick={() => {
|
||||
router.get(
|
||||
courseClassIndex.url(),
|
||||
{},
|
||||
{
|
||||
replace: true,
|
||||
preserveState: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Tampilkan semua
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CreateForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
courses={courses}
|
||||
lecturers={lecturers}
|
||||
academicTerms={academicTerms}
|
||||
/>
|
||||
|
||||
<EditForm
|
||||
key={editing?.id}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
editing={editing}
|
||||
courses={courses}
|
||||
lecturers={lecturers}
|
||||
academicTerms={academicTerms}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={courseClasses.data}
|
||||
searchKey="class_name"
|
||||
searchPlaceholder="Cari kelas mata kuliah..."
|
||||
emptyText="Belum ada data kelas mata kuliah."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Kelas Mata Kuliah"
|
||||
description={(courseClass) =>
|
||||
`Apakah Anda yakin ingin menghapus kelas "${courseClass.class_name ?? courseClass.course?.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
courses,
|
||||
lecturers,
|
||||
academicTerms,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
courses: Course[];
|
||||
lecturers: Lecturer[];
|
||||
academicTerms: AcademicTerm[];
|
||||
}) {
|
||||
const [courseId, setCourseId] = useState('');
|
||||
const [lecturerId, setLecturerId] = useState('');
|
||||
|
||||
const selectedCourse = courses.find((c) => String(c.id) === courseId);
|
||||
const availableLecturers = selectedCourse
|
||||
? lecturers.filter(
|
||||
(l) => l.department_id === selectedCourse.department_id,
|
||||
)
|
||||
: lecturers;
|
||||
|
||||
function reset() {
|
||||
setCourseId('');
|
||||
setLecturerId('');
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Tambah Kelas Mata Kuliah"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Mata Kuliah{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="course_id"
|
||||
value={courseId}
|
||||
/>
|
||||
<Select
|
||||
value={courseId}
|
||||
onValueChange={(value) => {
|
||||
setCourseId(value);
|
||||
setLecturerId('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih mata kuliah" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{courses.map((course) => (
|
||||
<SelectItem
|
||||
key={course.id}
|
||||
value={String(course.id)}
|
||||
>
|
||||
{course.code} - {course.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.course_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Dosen Pengampu{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="lecturer_id"
|
||||
value={lecturerId}
|
||||
/>
|
||||
<Select
|
||||
value={lecturerId}
|
||||
onValueChange={setLecturerId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
selectedCourse
|
||||
? 'Pilih dosen pengampu'
|
||||
: 'Pilih mata kuliah terlebih dahulu'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableLecturers.map((lect) => (
|
||||
<SelectItem
|
||||
key={lect.id}
|
||||
value={String(lect.id)}
|
||||
>
|
||||
{lect.user?.profile?.full_name ?? 'N/A'}{' '}
|
||||
- {lect.lecturer_number}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.lecturer_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Periode Akademik{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input type="hidden" name="academic_term_id" />
|
||||
<Select name="academic_term_id">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih periode akademik" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{academicTerms.map((term) => (
|
||||
<SelectItem
|
||||
key={term.id}
|
||||
value={String(term.id)}
|
||||
>
|
||||
{term.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.academic_term_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="class_name">Nama Kelas</Label>
|
||||
<Input
|
||||
id="class_name"
|
||||
name="class_name"
|
||||
placeholder="Contoh: SI-5A"
|
||||
/>
|
||||
<InputError message={errors.class_name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Metode</Label>
|
||||
<input type="hidden" name="method" />
|
||||
<Select name="method" defaultValue="hybrid">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih metode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ClassMethods.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
{ClassMethodLabels[method]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.method} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
courses,
|
||||
lecturers,
|
||||
academicTerms,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: CourseClass | null;
|
||||
courses: Course[];
|
||||
lecturers: Lecturer[];
|
||||
academicTerms: AcademicTerm[];
|
||||
}) {
|
||||
const [courseId, setCourseId] = useState(
|
||||
editing ? String(editing.course_id) : '',
|
||||
);
|
||||
const [lecturerId, setLecturerId] = useState(
|
||||
editing ? String(editing.lecturer_id) : '',
|
||||
);
|
||||
|
||||
const selectedCourse = courses.find((c) => String(c.id) === courseId);
|
||||
const availableLecturers = selectedCourse
|
||||
? lecturers.filter(
|
||||
(l) => l.department_id === selectedCourse.department_id,
|
||||
)
|
||||
: lecturers;
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Edit Kelas Mata Kuliah"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Mata Kuliah{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="course_id"
|
||||
value={courseId}
|
||||
/>
|
||||
<Select
|
||||
value={courseId}
|
||||
onValueChange={(value) => {
|
||||
setCourseId(value);
|
||||
setLecturerId('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih mata kuliah" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{courses.map((course) => (
|
||||
<SelectItem
|
||||
key={course.id}
|
||||
value={String(course.id)}
|
||||
>
|
||||
{course.code} - {course.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.course_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Dosen Pengampu{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="lecturer_id"
|
||||
value={lecturerId}
|
||||
/>
|
||||
<Select
|
||||
value={lecturerId}
|
||||
onValueChange={setLecturerId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih dosen pengampu" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableLecturers.map((lect) => (
|
||||
<SelectItem
|
||||
key={lect.id}
|
||||
value={String(lect.id)}
|
||||
>
|
||||
{lect.user?.profile?.full_name ??
|
||||
'N/A'}{' '}
|
||||
- {lect.lecturer_number}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.lecturer_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Periode Akademik{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
name="academic_term_id"
|
||||
defaultValue={String(editing.academic_term_id)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih periode akademik" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{academicTerms.map((term) => (
|
||||
<SelectItem
|
||||
key={term.id}
|
||||
value={String(term.id)}
|
||||
>
|
||||
{term.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.academic_term_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-class_name">Nama Kelas</Label>
|
||||
<Input
|
||||
id="edit-class_name"
|
||||
name="class_name"
|
||||
placeholder="Contoh: SI-5A"
|
||||
defaultValue={editing.class_name ?? ''}
|
||||
/>
|
||||
<InputError message={errors.class_name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Metode</Label>
|
||||
<Select
|
||||
name="method"
|
||||
defaultValue={editing.method ?? 'hybrid'}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih metode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ClassMethods.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
{ClassMethodLabels[method]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.method} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
105
resources/js/pages/admin/manage/courses/columns.tsx
Normal file
105
resources/js/pages/admin/manage/courses/columns.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Course } from '@/types/course';
|
||||
|
||||
export type { Course } from '@/types/course';
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (course: Course) => void;
|
||||
handleDeleteClick: (course: Course) => void;
|
||||
};
|
||||
|
||||
export function createCourseColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Course>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: () => <span>Kode</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{row.getValue('code') as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => <span>Nama Mata Kuliah</span>,
|
||||
cell: ({ row }) => <span>{row.getValue('name') as string}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'department.name',
|
||||
header: () => <span>Jurusan</span>,
|
||||
cell: ({ row }) => row.original.department?.name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'credits',
|
||||
header: () => <span className="block text-center">SKS</span>,
|
||||
meta: {
|
||||
className: 'w-[80px] text-center',
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="text-center">
|
||||
{row.getValue('credits') as number}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'semester_number',
|
||||
header: () => <span className="block text-center">Semester</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const semesterNumber = row.getValue('semester_number') as
|
||||
number | null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{semesterNumber ? (
|
||||
<Badge variant="secondary">{semesterNumber}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const course = row.original;
|
||||
|
||||
return (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(course),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(course),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
384
resources/js/pages/admin/manage/courses/index.tsx
Normal file
384
resources/js/pages/admin/manage/courses/index.tsx
Normal file
@ -0,0 +1,384 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
index as courseIndex,
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/manage/courses';
|
||||
import type { Course } from '@/types/course';
|
||||
import { createCourseColumns } from './columns';
|
||||
|
||||
type Department = { id: number; name: string };
|
||||
|
||||
type Props = {
|
||||
courses: {
|
||||
data: Course[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
departments: Department[];
|
||||
highlight?: number;
|
||||
};
|
||||
|
||||
export default function CourseIndex({
|
||||
courses,
|
||||
departments,
|
||||
highlight,
|
||||
}: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Course | null>(null);
|
||||
const [deleting, setDeleting] = useState<Course | null>(null);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: courses.current_page,
|
||||
last_page: courses.last_page,
|
||||
per_page: courses.per_page,
|
||||
total: courses.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => courseIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createCourseColumns({
|
||||
handleEdit: (course) => setEditing(course),
|
||||
handleDeleteClick: (course) => setDeleting(course),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Mata Kuliah" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Mata Kuliah"
|
||||
description={
|
||||
highlight && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Menampilkan mata kuliah dari notifikasi.
|
||||
<button
|
||||
onClick={() => {
|
||||
router.get(
|
||||
courseIndex.url(),
|
||||
{},
|
||||
{
|
||||
replace: true,
|
||||
preserveState: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Tampilkan semua
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CreateForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
departments={departments}
|
||||
/>
|
||||
|
||||
<EditForm
|
||||
key={editing?.id}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
editing={editing}
|
||||
departments={departments}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={courses.data}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Cari mata kuliah..."
|
||||
emptyText="Belum ada data mata kuliah."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Mata Kuliah"
|
||||
description={(course) =>
|
||||
`Apakah Anda yakin ingin menghapus mata kuliah "${course.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
departments,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
departments: Department[];
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Tambah Mata Kuliah"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="code">
|
||||
Kode <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
placeholder="Contoh: SI301"
|
||||
/>
|
||||
<InputError message={errors.code} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama mata kuliah"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jurusan <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input type="hidden" name="department_id" />
|
||||
<Select name="department_id">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih jurusan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem
|
||||
key={dept.id}
|
||||
value={String(dept.id)}
|
||||
>
|
||||
{dept.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.department_id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="credits">
|
||||
SKS <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
name="credits"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
placeholder="Contoh: 3"
|
||||
/>
|
||||
<InputError message={errors.credits} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="semester_number">Semester</Label>
|
||||
<Input
|
||||
id="semester_number"
|
||||
name="semester_number"
|
||||
type="number"
|
||||
min={1}
|
||||
max={14}
|
||||
placeholder="Contoh: 5"
|
||||
/>
|
||||
<InputError message={errors.semester_number} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
departments,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: Course | null;
|
||||
departments: Department[];
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Edit Mata Kuliah"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-code">
|
||||
Kode <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-code"
|
||||
name="code"
|
||||
placeholder="Contoh: SI301"
|
||||
defaultValue={editing.code}
|
||||
/>
|
||||
<InputError message={errors.code} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama mata kuliah"
|
||||
defaultValue={editing.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jurusan{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
name="department_id"
|
||||
defaultValue={String(editing.department_id)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih jurusan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem
|
||||
key={dept.id}
|
||||
value={String(dept.id)}
|
||||
>
|
||||
{dept.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.department_id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-credits">
|
||||
SKS{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-credits"
|
||||
name="credits"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
defaultValue={editing.credits}
|
||||
/>
|
||||
<InputError message={errors.credits} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-semester_number">
|
||||
Semester
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-semester_number"
|
||||
name="semester_number"
|
||||
type="number"
|
||||
min={1}
|
||||
max={14}
|
||||
defaultValue={
|
||||
editing.semester_number ?? undefined
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.semester_number} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
16
resources/js/types/class-enrollment.ts
Normal file
16
resources/js/types/class-enrollment.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export type EnrollmentStudent = {
|
||||
id: number;
|
||||
student_number: string;
|
||||
department: { id: number; name: string } | null;
|
||||
user: { profile: { full_name: string } | null } | null;
|
||||
};
|
||||
|
||||
export type ClassEnrollment = {
|
||||
id: number;
|
||||
course_class_id: number;
|
||||
student_id: number;
|
||||
student: EnrollmentStudent | null;
|
||||
enrolled_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
34
resources/js/types/course-class.ts
Normal file
34
resources/js/types/course-class.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export const ClassMethods = ['online', 'offline', 'hybrid'] as const;
|
||||
|
||||
export type ClassMethod = (typeof ClassMethods)[number];
|
||||
|
||||
export const ClassMethodLabels: Record<ClassMethod, string> = {
|
||||
online: 'Daring',
|
||||
offline: 'Luring',
|
||||
hybrid: 'Hybrid',
|
||||
};
|
||||
|
||||
export type CourseClass = {
|
||||
id: number;
|
||||
course_id: number;
|
||||
course: {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
department_id: number;
|
||||
} | null;
|
||||
lecturer_id: number;
|
||||
lecturer: {
|
||||
id: number;
|
||||
lecturer_number: string;
|
||||
department_id: number;
|
||||
user: { profile: { full_name: string } | null } | null;
|
||||
} | null;
|
||||
academic_term_id: number;
|
||||
academic_term: { id: number; name: string; semester: string } | null;
|
||||
class_name: string | null;
|
||||
method: ClassMethod | null;
|
||||
enrollments_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
11
resources/js/types/course.ts
Normal file
11
resources/js/types/course.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type Course = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
department_id: number;
|
||||
department: { id: number; name: string } | null;
|
||||
semester_number: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Manage\ClassEnrollmentController;
|
||||
use App\Http\Controllers\Admin\Manage\CourseClassController;
|
||||
use App\Http\Controllers\Admin\Manage\CourseController;
|
||||
use App\Http\Controllers\Admin\Master\AcademicTermController;
|
||||
use App\Http\Controllers\Admin\Master\DepartmentController;
|
||||
use App\Http\Controllers\Admin\Users\AdministratorController;
|
||||
@ -13,6 +16,17 @@
|
||||
Route::resource('departments', DepartmentController::class)->except(['create', 'edit', 'show']);
|
||||
});
|
||||
|
||||
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
||||
Route::resource('courses', CourseController::class)->except(['create', 'edit', 'show']);
|
||||
Route::resource('course-classes', CourseClassController::class)->except(['create', 'edit', 'show']);
|
||||
|
||||
Route::prefix('course-classes/{course_class}/enrollments')->name('course-classes.enrollments.')->group(function () {
|
||||
Route::get('/', [ClassEnrollmentController::class, 'index'])->name('index');
|
||||
Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store');
|
||||
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('admin/users')->name('admin.users.')->group(function () {
|
||||
Route::resource('lecturers', LecturerController::class)->except(['show'])->parameters(['lecturers' => 'user']);
|
||||
Route::patch('lecturers/{user}/reset-password', [LecturerController::class, 'resetPassword'])->name('lecturers.reset_password');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user