feat: add course registration management functionality with CRUD operations
Some checks failed
tests / ci (pull_request) Has been cancelled
Some checks failed
tests / ci (pull_request) Has been cancelled
This commit is contained in:
parent
4ff18c23c5
commit
2f23747781
19
app/Enums/RegistrationStatus.php
Normal file
19
app/Enums/RegistrationStatus.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RegistrationStatus: string
|
||||
{
|
||||
case Submitted = 'submitted';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Submitted => 'Diajukan',
|
||||
self::Approved => 'Disetujui',
|
||||
self::Rejected => 'Ditolak',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CourseRegistrationRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\CourseRegistration;
|
||||
use App\Services\Admin\Manage\CourseClassService;
|
||||
use App\Services\Admin\Manage\CourseRegistrationService;
|
||||
use App\Services\Admin\Master\AcademicTermService;
|
||||
use App\Services\Admin\Users\LecturerService;
|
||||
use App\Services\Admin\Users\StudentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CourseRegistrationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseRegistrationService $service,
|
||||
private readonly StudentService $studentService,
|
||||
private readonly AcademicTermService $academicTermService,
|
||||
private readonly CourseClassService $courseClassService,
|
||||
private readonly LecturerService $lecturerService,
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/course-registrations/index', [
|
||||
'registrations' => $this->service->paginated(...$request->validatedWithDefaults()),
|
||||
'students' => $this->studentService->getAllForSelect(),
|
||||
'academicTerms' => $this->academicTermService->getAll(),
|
||||
'courseClasses' => $this->courseClassService->getAllForSelect(),
|
||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CourseRegistrationRequest $request): RedirectResponse
|
||||
{
|
||||
$this->service->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']);
|
||||
|
||||
return to_route('admin.manage.course-registrations.index');
|
||||
}
|
||||
|
||||
public function update(CourseRegistrationRequest $request, CourseRegistration $courseRegistration): RedirectResponse
|
||||
{
|
||||
$this->service->update($courseRegistration, $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil diperbarui.']);
|
||||
|
||||
return to_route('admin.manage.course-registrations.index');
|
||||
}
|
||||
|
||||
public function destroy(CourseRegistration $courseRegistration): RedirectResponse
|
||||
{
|
||||
$this->service->delete($courseRegistration);
|
||||
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil dihapus.'])->back();
|
||||
}
|
||||
}
|
||||
49
app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php
Normal file
49
app/Http/Requests/Admin/Manage/CourseRegistrationRequest.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\RegistrationStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CourseRegistrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$registration = $this->route('course_registration');
|
||||
|
||||
return [
|
||||
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
||||
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
||||
'course_class_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('course_classes', 'id'),
|
||||
Rule::unique('course_registrations')
|
||||
->where('student_id', $this->input('student_id'))
|
||||
->ignore($registration?->id),
|
||||
],
|
||||
'status' => ['nullable', 'string', Rule::in(array_column(RegistrationStatus::cases(), 'value'))],
|
||||
'approved_by' => ['nullable', 'integer', Rule::exists('lecturers', 'id')],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'course_class_id' => 'kelas mata kuliah',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'course_class_id.unique' => 'Mahasiswa ini sudah terdaftar pada kelas mata kuliah tersebut.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -61,4 +61,9 @@ public function attendances(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attendance::class);
|
||||
}
|
||||
|
||||
public function registrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CourseRegistration::class);
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Models/CourseRegistration.php
Normal file
42
app/Models/CourseRegistration.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RegistrationStatus;
|
||||
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 CourseRegistration extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => RegistrationStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function academicTerm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicTerm::class);
|
||||
}
|
||||
|
||||
public function courseClass(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CourseClass::class);
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lecturer::class, 'approved_by');
|
||||
}
|
||||
}
|
||||
@ -56,4 +56,9 @@ public function tuitionInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(TuitionInvoice::class);
|
||||
}
|
||||
|
||||
public function courseRegistrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CourseRegistration::class);
|
||||
}
|
||||
}
|
||||
|
||||
79
app/Services/Admin/Manage/CourseRegistrationService.php
Normal file
79
app/Services/Admin/Manage/CourseRegistrationService.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Enums\RegistrationStatus;
|
||||
use App\Models\ClassEnrollment;
|
||||
use App\Models\CourseRegistration;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class CourseRegistrationService
|
||||
{
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return CourseRegistration::query()
|
||||
->select(['id', 'student_id', 'academic_term_id', 'course_class_id', 'status', 'approved_by', 'created_at'])
|
||||
->with([
|
||||
'student.user.profile',
|
||||
'student.department',
|
||||
'academicTerm:id,name,semester,start_date,end_date',
|
||||
'courseClass.course:id,code,name',
|
||||
'approver.user.profile',
|
||||
])
|
||||
->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
|
||||
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function create(array $data): CourseRegistration
|
||||
{
|
||||
$registration = CourseRegistration::create([
|
||||
'student_id' => $data['student_id'],
|
||||
'academic_term_id' => $data['academic_term_id'],
|
||||
'course_class_id' => $data['course_class_id'],
|
||||
'status' => $data['status'] ?? RegistrationStatus::Submitted->value,
|
||||
'approved_by' => $data['approved_by'] ?? null,
|
||||
]);
|
||||
|
||||
$this->syncEnrollment($registration);
|
||||
|
||||
return $registration;
|
||||
}
|
||||
|
||||
public function update(CourseRegistration $registration, array $data): CourseRegistration
|
||||
{
|
||||
$registration->student_id = $data['student_id'];
|
||||
$registration->academic_term_id = $data['academic_term_id'];
|
||||
$registration->course_class_id = $data['course_class_id'];
|
||||
$registration->status = $data['status'] ?? RegistrationStatus::Submitted->value;
|
||||
$registration->approved_by = $data['approved_by'] ?? null;
|
||||
$registration->update();
|
||||
|
||||
$this->syncEnrollment($registration);
|
||||
|
||||
return $registration;
|
||||
}
|
||||
|
||||
public function delete(CourseRegistration $registration): bool
|
||||
{
|
||||
return $registration->delete();
|
||||
}
|
||||
|
||||
private function syncEnrollment(CourseRegistration $registration): void
|
||||
{
|
||||
if ($registration->status !== RegistrationStatus::Approved) {
|
||||
return;
|
||||
}
|
||||
|
||||
ClassEnrollment::firstOrCreate(
|
||||
[
|
||||
'course_class_id' => $registration->course_class_id,
|
||||
'student_id' => $registration->student_id,
|
||||
],
|
||||
[
|
||||
'enrolled_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\RegistrationStatus;
|
||||
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_registrations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('course_class_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('status', array_values(RegistrationStatus::cases()))->nullable()->default(RegistrationStatus::Submitted->value);
|
||||
$table->foreignId('approved_by')->nullable()->constrained('lecturers')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['student_id', 'course_class_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('course_registrations');
|
||||
}
|
||||
};
|
||||
33
database/seeders/CourseRegistrationSeeder.php
Normal file
33
database/seeders/CourseRegistrationSeeder.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CourseRegistration;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CourseRegistrationSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
CourseRegistration::insert([
|
||||
[
|
||||
'student_id' => 1,
|
||||
'academic_term_id' => 2,
|
||||
'course_class_id' => 1,
|
||||
'status' => 'approved',
|
||||
'approved_by' => 1,
|
||||
'created_at' => '2026-01-20 08:00:00',
|
||||
'updated_at' => '2026-01-20 13:00:00',
|
||||
],
|
||||
[
|
||||
'student_id' => 2,
|
||||
'academic_term_id' => 2,
|
||||
'course_class_id' => 2,
|
||||
'status' => 'submitted',
|
||||
'approved_by' => null,
|
||||
'created_at' => '2026-01-20 08:10:00',
|
||||
'updated_at' => '2026-01-20 08:10:00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ public function run(): void
|
||||
CourseSeeder::class,
|
||||
CourseClassSeeder::class,
|
||||
ClassEnrollmentSeeder::class,
|
||||
CourseRegistrationSeeder::class,
|
||||
MaterialSeeder::class,
|
||||
AssignmentSeeder::class,
|
||||
SubmissionSeeder::class,
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
CalendarClock,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
Receipt,
|
||||
@ -37,6 +38,7 @@ import {
|
||||
import { index as assignmentsRoute } from '@/routes/admin/manage/assignments';
|
||||
import { index as attendancesRoute } from '@/routes/admin/manage/attendances';
|
||||
import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes';
|
||||
import { index as courseRegistrationsRoute } from '@/routes/admin/manage/course-registrations';
|
||||
import { index as coursesRoute } from '@/routes/admin/manage/courses';
|
||||
import { index as materialsRoute } from '@/routes/admin/manage/materials';
|
||||
import { index as schedulesRoute } from '@/routes/admin/manage/schedules';
|
||||
@ -90,6 +92,11 @@ const data: {
|
||||
{
|
||||
label: 'Kelas',
|
||||
items: [
|
||||
{
|
||||
name: 'Registrasi KRS',
|
||||
url: courseRegistrationsRoute.url(),
|
||||
icon: FileCheck2,
|
||||
},
|
||||
{
|
||||
name: 'Materi',
|
||||
url: materialsRoute.url(),
|
||||
|
||||
141
resources/js/pages/admin/manage/course-registrations/columns.tsx
Normal file
141
resources/js/pages/admin/manage/course-registrations/columns.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { format } from 'date-fns';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type {
|
||||
CourseRegistration,
|
||||
RegistrationStatus,
|
||||
} from '@/types/course-registration';
|
||||
import { RegistrationStatusLabels } from '@/types/course-registration';
|
||||
|
||||
export type { CourseRegistration } from '@/types/course-registration';
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (registration: CourseRegistration) => void;
|
||||
handleDeleteClick: (registration: CourseRegistration) => void;
|
||||
};
|
||||
|
||||
export function createCourseRegistrationColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<CourseRegistration>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'student.student_number',
|
||||
header: () => <span>Mahasiswa</span>,
|
||||
cell: ({ row }) => {
|
||||
const student = row.original.student;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{student?.user?.profile?.full_name ?? 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{student?.student_number} ·{' '}
|
||||
{student?.department?.name ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'course_class.course.name',
|
||||
header: () => <span>Kelas Mata Kuliah</span>,
|
||||
cell: ({ row }) => {
|
||||
const courseClass = row.original.course_class;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{courseClass?.course?.name ?? 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{courseClass?.course?.code}
|
||||
{courseClass?.class_name
|
||||
? ` · ${courseClass.class_name}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'academic_term.name',
|
||||
header: () => <span>Periode</span>,
|
||||
cell: ({ row }) => row.original.academic_term?.name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => <span className="block text-center">Status</span>,
|
||||
meta: {
|
||||
className: 'w-[130px] text-center',
|
||||
headerClassName: 'w-[130px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue(
|
||||
'status',
|
||||
) as RegistrationStatus | null;
|
||||
|
||||
const variant =
|
||||
status === 'approved'
|
||||
? 'default'
|
||||
: status === 'rejected'
|
||||
? 'destructive'
|
||||
: 'outline';
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{status ? (
|
||||
<Badge variant={variant}>
|
||||
{RegistrationStatusLabels[status]}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'approver.user.profile.full_name',
|
||||
header: () => <span>Disetujui Oleh</span>,
|
||||
cell: ({ row }) =>
|
||||
row.original.approver?.user?.profile?.full_name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => <span>Diajukan</span>,
|
||||
cell: ({ row }) =>
|
||||
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(row.original),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(row.original),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
428
resources/js/pages/admin/manage/course-registrations/index.tsx
Normal file
428
resources/js/pages/admin/manage/course-registrations/index.tsx
Normal file
@ -0,0 +1,428 @@
|
||||
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 { 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 courseRegistrationIndex,
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/manage/course-registrations';
|
||||
import type {
|
||||
CourseRegistration,
|
||||
CourseRegistrationCourseClass,
|
||||
CourseRegistrationStudent,
|
||||
} from '@/types/course-registration';
|
||||
import {
|
||||
RegistrationStatuses,
|
||||
RegistrationStatusLabels,
|
||||
} from '@/types/course-registration';
|
||||
import { createCourseRegistrationColumns } from './columns';
|
||||
|
||||
type AcademicTermOption = { id: number; name: string; semester: string };
|
||||
type LecturerOption = {
|
||||
id: number;
|
||||
lecturer_number: string;
|
||||
user: { profile: { full_name: string } | null } | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
registrations: {
|
||||
data: CourseRegistration[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
students: CourseRegistrationStudent[];
|
||||
academicTerms: AcademicTermOption[];
|
||||
courseClasses: CourseRegistrationCourseClass[];
|
||||
lecturers: LecturerOption[];
|
||||
highlight?: number;
|
||||
};
|
||||
|
||||
function studentLabel(student: CourseRegistrationStudent): string {
|
||||
return `${student.user?.profile?.full_name ?? 'N/A'} - ${student.student_number}`;
|
||||
}
|
||||
|
||||
function courseClassLabel(courseClass: CourseRegistrationCourseClass): string {
|
||||
const course = courseClass.course;
|
||||
const suffix = courseClass.class_name ? ` (${courseClass.class_name})` : '';
|
||||
|
||||
return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}${suffix}`;
|
||||
}
|
||||
|
||||
function lecturerLabel(lecturer: LecturerOption): string {
|
||||
return `${lecturer.user?.profile?.full_name ?? 'N/A'} - ${lecturer.lecturer_number}`;
|
||||
}
|
||||
|
||||
export default function CourseRegistrationIndex({
|
||||
registrations,
|
||||
students,
|
||||
academicTerms,
|
||||
courseClasses,
|
||||
lecturers,
|
||||
highlight,
|
||||
}: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<CourseRegistration | null>(null);
|
||||
const [deleting, setDeleting] = useState<CourseRegistration | null>(null);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: registrations.current_page,
|
||||
last_page: registrations.last_page,
|
||||
per_page: registrations.per_page,
|
||||
total: registrations.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => courseRegistrationIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createCourseRegistrationColumns({
|
||||
handleEdit: (registration) => setEditing(registration),
|
||||
handleDeleteClick: (registration) => setDeleting(registration),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Registrasi KRS" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Registrasi KRS"
|
||||
description={
|
||||
highlight && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Menampilkan registrasi dari notifikasi.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CreateForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
students={students}
|
||||
academicTerms={academicTerms}
|
||||
courseClasses={courseClasses}
|
||||
lecturers={lecturers}
|
||||
/>
|
||||
|
||||
<EditForm
|
||||
key={editing?.id}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
editing={editing}
|
||||
students={students}
|
||||
academicTerms={academicTerms}
|
||||
courseClasses={courseClasses}
|
||||
lecturers={lecturers}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={registrations.data}
|
||||
emptyText="Belum ada data registrasi KRS."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
searchKey="student"
|
||||
searchPlaceholder="Cari nama atau NIM mahasiswa..."
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Registrasi"
|
||||
description={(registration) =>
|
||||
`Apakah Anda yakin ingin menghapus registrasi KRS untuk "${registration.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrationFields({
|
||||
errors,
|
||||
editing,
|
||||
students,
|
||||
academicTerms,
|
||||
courseClasses,
|
||||
lecturers,
|
||||
}: {
|
||||
errors: Record<string, string>;
|
||||
editing?: CourseRegistration;
|
||||
students: CourseRegistrationStudent[];
|
||||
academicTerms: AcademicTermOption[];
|
||||
courseClasses: CourseRegistrationCourseClass[];
|
||||
lecturers: LecturerOption[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Mahasiswa <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{!editing && <input type="hidden" name="student_id" />}
|
||||
<Select
|
||||
name="student_id"
|
||||
defaultValue={
|
||||
editing ? String(editing.student_id) : undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih mahasiswa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{students.map((student) => (
|
||||
<SelectItem
|
||||
key={student.id}
|
||||
value={String(student.id)}
|
||||
>
|
||||
{studentLabel(student)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.student_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Kelas Mata Kuliah{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{!editing && <input type="hidden" name="course_class_id" />}
|
||||
<Select
|
||||
name="course_class_id"
|
||||
defaultValue={
|
||||
editing ? String(editing.course_class_id) : undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih kelas mata kuliah" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{courseClasses.map((courseClass) => (
|
||||
<SelectItem
|
||||
key={courseClass.id}
|
||||
value={String(courseClass.id)}
|
||||
>
|
||||
{courseClassLabel(courseClass)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.course_class_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Periode Akademik <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{!editing && <input type="hidden" name="academic_term_id" />}
|
||||
<Select
|
||||
name="academic_term_id"
|
||||
defaultValue={
|
||||
editing ? String(editing.academic_term_id) : undefined
|
||||
}
|
||||
>
|
||||
<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>Status</Label>
|
||||
<input type="hidden" name="status" />
|
||||
<Select
|
||||
name="status"
|
||||
defaultValue={editing?.status ?? 'submitted'}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RegistrationStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{RegistrationStatusLabels[status]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.status} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Disetujui Oleh</Label>
|
||||
<input type="hidden" name="approved_by" />
|
||||
<Select
|
||||
name="approved_by"
|
||||
defaultValue={
|
||||
editing?.approved_by
|
||||
? String(editing.approved_by)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih dosen (jika disetujui)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lecturers.map((lecturer) => (
|
||||
<SelectItem
|
||||
key={lecturer.id}
|
||||
value={String(lecturer.id)}
|
||||
>
|
||||
{lecturerLabel(lecturer)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.approved_by} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
students,
|
||||
academicTerms,
|
||||
courseClasses,
|
||||
lecturers,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
students: CourseRegistrationStudent[];
|
||||
academicTerms: AcademicTermOption[];
|
||||
courseClasses: CourseRegistrationCourseClass[];
|
||||
lecturers: LecturerOption[];
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Tambah Registrasi KRS"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4">
|
||||
<RegistrationFields
|
||||
errors={errors}
|
||||
students={students}
|
||||
academicTerms={academicTerms}
|
||||
courseClasses={courseClasses}
|
||||
lecturers={lecturers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
students,
|
||||
academicTerms,
|
||||
courseClasses,
|
||||
lecturers,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: CourseRegistration | null;
|
||||
students: CourseRegistrationStudent[];
|
||||
academicTerms: AcademicTermOption[];
|
||||
courseClasses: CourseRegistrationCourseClass[];
|
||||
lecturers: LecturerOption[];
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Edit Registrasi KRS"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4">
|
||||
<RegistrationFields
|
||||
errors={errors}
|
||||
editing={editing}
|
||||
students={students}
|
||||
academicTerms={academicTerms}
|
||||
courseClasses={courseClasses}
|
||||
lecturers={lecturers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
46
resources/js/types/course-registration.ts
Normal file
46
resources/js/types/course-registration.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export const RegistrationStatuses = [
|
||||
'submitted',
|
||||
'approved',
|
||||
'rejected',
|
||||
] as const;
|
||||
|
||||
export type RegistrationStatus = (typeof RegistrationStatuses)[number];
|
||||
|
||||
export const RegistrationStatusLabels: Record<RegistrationStatus, string> = {
|
||||
submitted: 'Diajukan',
|
||||
approved: 'Disetujui',
|
||||
rejected: 'Ditolak',
|
||||
};
|
||||
|
||||
export type CourseRegistrationStudent = {
|
||||
id: number;
|
||||
student_number: string;
|
||||
department: { id: number; name: string } | null;
|
||||
user: { profile: { full_name: string } | null } | null;
|
||||
};
|
||||
|
||||
export type CourseRegistrationCourseClass = {
|
||||
id: number;
|
||||
class_name: string | null;
|
||||
course: { id: number; code: string; name: string } | null;
|
||||
};
|
||||
|
||||
export type CourseRegistrationApprover = {
|
||||
id: number;
|
||||
user: { profile: { full_name: string } | null } | null;
|
||||
};
|
||||
|
||||
export type CourseRegistration = {
|
||||
id: number;
|
||||
student_id: number;
|
||||
student: CourseRegistrationStudent | null;
|
||||
academic_term_id: number;
|
||||
academic_term: { id: number; name: string; semester: string } | null;
|
||||
course_class_id: number;
|
||||
course_class: CourseRegistrationCourseClass | null;
|
||||
status: RegistrationStatus | null;
|
||||
approved_by: number | null;
|
||||
approver: CourseRegistrationApprover | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@ -5,6 +5,7 @@
|
||||
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\Manage\CourseRegistrationController;
|
||||
use App\Http\Controllers\Admin\Manage\MaterialController;
|
||||
use App\Http\Controllers\Admin\Manage\ScheduleController;
|
||||
use App\Http\Controllers\Admin\Manage\SubmissionController;
|
||||
@ -33,6 +34,8 @@
|
||||
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
Route::resource('course-registrations', CourseRegistrationController::class)->except(['create', 'edit', 'show']);
|
||||
|
||||
Route::resource('materials', MaterialController::class)->except(['create', 'edit', 'show']);
|
||||
|
||||
Route::resource('assignments', AssignmentController::class)->except(['create', 'edit', 'show']);
|
||||
|
||||
157
storage/framework/lsp-fe894e15fb6bf7ea.php
Normal file
157
storage/framework/lsp-fe894e15fb6bf7ea.php
Normal file
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
error_reporting(error_reporting() & ~(E_WARNING | E_CORE_WARNING | E_COMPILE_WARNING | E_USER_WARNING | E_DEPRECATED | E_USER_DEPRECATED));
|
||||
class LspHelper
|
||||
{
|
||||
public static function relativePath($path)
|
||||
{
|
||||
if (!str_contains($path, base_path())) {
|
||||
return (string) $path;
|
||||
}
|
||||
|
||||
return ltrim(str_replace(base_path(), '', realpath($path) ?: $path), DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public static function isVendor($path)
|
||||
{
|
||||
return str_contains($path, base_path('vendor'));
|
||||
}
|
||||
|
||||
public static function propertyDefault(ReflectionProperty $property, ?ReflectionParameter $parameter = null): array
|
||||
{
|
||||
if ($property->hasDefaultValue()) {
|
||||
return ['default' => $property->getDefaultValue()];
|
||||
}
|
||||
|
||||
if ($parameter?->isDefaultValueAvailable()) {
|
||||
return ['default' => $parameter->getDefaultValue()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function formatDefaultValue(mixed $value): mixed
|
||||
{
|
||||
return match (true) {
|
||||
is_array($value) => 'array(...)',
|
||||
$value instanceof UnitEnum => get_class($value) . '::' . $value->name,
|
||||
$value instanceof Closure => 'Closure',
|
||||
is_object($value) => get_class($value),
|
||||
is_string($value) => var_export($value, true),
|
||||
is_null($value) => 'null',
|
||||
is_bool($value) => $value ? 'true' : 'false',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Folio\FolioManager;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
$routes = new class
|
||||
{
|
||||
public function all()
|
||||
{
|
||||
return collect(app('router')->getRoutes()->getRoutes())
|
||||
->map(fn (Route $route) => $this->getRoute($route))
|
||||
->merge($this->getFolioRoutes());
|
||||
}
|
||||
|
||||
protected function getFolioRoutes()
|
||||
{
|
||||
try {
|
||||
$output = new BufferedOutput;
|
||||
|
||||
Artisan::call('folio:list', ['--json' => true], $output);
|
||||
|
||||
$mountPaths = collect(app(FolioManager::class)->mountPaths());
|
||||
|
||||
return collect(json_decode($output->fetch(), true))->map(fn ($route) => $this->getFolioRoute($route, $mountPaths));
|
||||
} catch (Exception|Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
protected function getFolioRoute($route, $mountPaths)
|
||||
{
|
||||
if ($mountPaths->count() === 1) {
|
||||
$mountPath = $mountPaths[0];
|
||||
} else {
|
||||
$mountPath = $mountPaths->first(fn ($mp) => file_exists($mp->path . DIRECTORY_SEPARATOR . $route['view']));
|
||||
}
|
||||
|
||||
$path = $route['view'];
|
||||
|
||||
if ($mountPath) {
|
||||
$path = $mountPath->path . DIRECTORY_SEPARATOR . $path;
|
||||
}
|
||||
|
||||
return [
|
||||
'method' => $route['method'],
|
||||
'uri' => $route['uri'],
|
||||
'name' => $route['name'],
|
||||
'action' => null,
|
||||
'parameters' => [],
|
||||
'filename' => LspHelper::relativePath($path),
|
||||
'line' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRoute(Route $route)
|
||||
{
|
||||
try {
|
||||
$reflection = $this->getRouteReflection($route);
|
||||
} catch (Throwable $e) {
|
||||
$reflection = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'method' => collect($route->methods())
|
||||
->filter(fn ($method) => $method !== 'HEAD')
|
||||
->implode('|'),
|
||||
'uri' => $route->uri(),
|
||||
'name' => $route->getName(),
|
||||
'action' => $route->getActionName(),
|
||||
'parameters' => $route->parameterNames(),
|
||||
'filename' => $reflection ? LspHelper::relativePath($reflection->getFileName()) : null,
|
||||
'line' => $reflection ? $reflection->getStartLine() : null,
|
||||
'livewire' => $this->getLivewireView($route),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getLivewireView(Route $route): ?string
|
||||
{
|
||||
if ($route->getActionName() !== 'Livewire\Features\SupportRouting\LivewirePageController') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $route->defaults['_livewire_component'] ?? null;
|
||||
}
|
||||
|
||||
protected function getRouteReflection(Route $route)
|
||||
{
|
||||
if ($route->getActionName() === 'Closure') {
|
||||
return new ReflectionFunction($route->getAction()['uses']);
|
||||
}
|
||||
|
||||
if (!str_contains($route->getActionName(), '@')) {
|
||||
return new ReflectionClass($route->getActionName());
|
||||
}
|
||||
|
||||
try {
|
||||
return new ReflectionMethod($route->getControllerClass(), $route->getActionMethod());
|
||||
} catch (Throwable $e) {
|
||||
$namespace = app(UrlGenerator::class)->getRootControllerNamespace()
|
||||
?? (app()->getNamespace() . 'Http\Controllers');
|
||||
|
||||
return new ReflectionMethod(
|
||||
$namespace . '\\' . ltrim($route->getControllerClass(), '\\'),
|
||||
$route->getActionMethod(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echo $routes->all()->toJson();
|
||||
Loading…
Reference in New Issue
Block a user