feat: add course registration management functionality with CRUD operations
Some checks failed
tests / ci (pull_request) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-08-25 00:51:50 +07:00
parent 4ff18c23c5
commit 2f23747781
16 changed files with 1107 additions and 0 deletions

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

View File

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

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

View File

@ -61,4 +61,9 @@ public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
public function registrations(): HasMany
{
return $this->hasMany(CourseRegistration::class);
}
}

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

View File

@ -56,4 +56,9 @@ public function tuitionInvoices(): HasMany
{
return $this->hasMany(TuitionInvoice::class);
}
public function courseRegistrations(): HasMany
{
return $this->hasMany(CourseRegistration::class);
}
}

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

View File

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

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

View File

@ -20,6 +20,7 @@ public function run(): void
CourseSeeder::class,
CourseClassSeeder::class,
ClassEnrollmentSeeder::class,
CourseRegistrationSeeder::class,
MaterialSeeder::class,
AssignmentSeeder::class,
SubmissionSeeder::class,

View File

@ -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(),

View 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} &middot;{' '}
{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),
},
]}
/>
),
},
];
}

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

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

View File

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

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