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:
Yoga Pangestu 2026-08-21 19:06:05 +07:00
parent 8c81e990b8
commit 015cbc229b
31 changed files with 2227 additions and 1 deletions

19
app/Enums/ClassMethod.php Normal file
View 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',
};
}
}

View File

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

View 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();
}
}

View 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();
}
}

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

View 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'))],
];
}
}

View 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'],
];
}
}

View 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
View 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);
}
}

View 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);
}
}

View File

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

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

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

View File

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

View File

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

View 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',
],
]);
}
}

View 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',
],
]);
}
}

View 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',
],
]);
}
}

View File

@ -17,6 +17,9 @@ public function run(): void
UserSeeder::class,
AcademicTermSeeder::class,
DepartmentLeadershipSeeder::class,
CourseSeeder::class,
CourseClassSeeder::class,
ClassEnrollmentSeeder::class,
]);
}
}

View File

@ -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: [

View 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),
},
]}
/>
);
},
},
];
}

View 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>
);
}

View 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>
);
}

View 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),
},
]}
/>
);
},
},
];
}

View 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>
);
}

View 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;
};

View 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;
};

View 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;
};

View File

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