feat: add course registration management for admin and student
Some checks failed
tests / ci (pull_request) Has been cancelled
Some checks failed
tests / ci (pull_request) Has been cancelled
- Implemented CourseRegistrationShow component for admin to view registration details. - Added course registration creation and listing for students. - Enhanced student registration edit and create forms with improved error handling. - Introduced new columns for course registration submissions in student view. - Updated routes for course registration management in admin and student contexts. - Added logging and status handling for course registration submissions. - Improved type definitions for course registration and user roles.
This commit is contained in:
parent
62da33936b
commit
8336504032
@ -4,14 +4,16 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\CourseRegistrationRequest;
|
use App\Http\Requests\Admin\Manage\CourseRegistrationRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\RejectCourseRegistrationRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\CourseRegistration;
|
use App\Models\CourseRegistrationSubmission;
|
||||||
|
use App\Models\Student;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
use App\Services\Admin\Manage\CourseClassService;
|
||||||
use App\Services\Admin\Manage\CourseRegistrationService;
|
use App\Services\Admin\Manage\CourseRegistrationService;
|
||||||
use App\Services\Admin\Master\AcademicTermService;
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use App\Services\Admin\Users\LecturerService;
|
|
||||||
use App\Services\Admin\Users\StudentService;
|
use App\Services\Admin\Users\StudentService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@ -22,7 +24,6 @@ public function __construct(
|
|||||||
private readonly StudentService $studentService,
|
private readonly StudentService $studentService,
|
||||||
private readonly AcademicTermService $academicTermService,
|
private readonly AcademicTermService $academicTermService,
|
||||||
private readonly CourseClassService $courseClassService,
|
private readonly CourseClassService $courseClassService,
|
||||||
private readonly LecturerService $lecturerService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
@ -36,13 +37,84 @@ public function index(PaginatedRequest $request): Response
|
|||||||
'students' => $this->studentService->getAllForSelect(),
|
'students' => $this->studentService->getAllForSelect(),
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||||
'courseClasses' => $this->courseClassService->getAllForSelect(),
|
'courseClasses' => $this->courseClassService->getAllForSelect(),
|
||||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
|
||||||
'filters' => $request->only(['status', 'academic_term_id']),
|
'filters' => $request->only(['status', 'academic_term_id']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mine(Request $request): Response
|
||||||
|
{
|
||||||
|
$student = $this->currentStudent($request);
|
||||||
|
|
||||||
|
$term = $this->academicTermService->getActive();
|
||||||
|
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
|
||||||
|
$activeSubmission = $term ? $this->service->currentSubmission($student, $term) : null;
|
||||||
|
|
||||||
|
return Inertia::render('student/course-registrations/index', [
|
||||||
|
'academicTerm' => $term,
|
||||||
|
'payment' => $payment,
|
||||||
|
'canSubmit' => $this->service->canSubmit($activeSubmission),
|
||||||
|
'activeSubmission' => $activeSubmission,
|
||||||
|
'submissions' => $this->service->submissionsFor($student),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$student = $this->currentStudent($request);
|
||||||
|
|
||||||
|
$student->load(['user.profile', 'department', 'academicAdvisor.user.profile']);
|
||||||
|
|
||||||
|
$term = $this->academicTermService->getActive();
|
||||||
|
$payment = $term ? $this->service->paymentStatus($student, $term) : null;
|
||||||
|
$submission = $term ? $this->service->currentSubmission($student, $term) : null;
|
||||||
|
|
||||||
|
if (! $term || ! $payment['is_paid'] || ! $this->service->canSubmit($submission)) {
|
||||||
|
return to_route('student.course-registrations.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('student/course-registrations/create', [
|
||||||
|
'student' => $student,
|
||||||
|
'academicTerm' => $term,
|
||||||
|
'submission' => $submission,
|
||||||
|
'availableCourses' => $this->service->availableCourseClasses($student, $term),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, CourseRegistrationSubmission $submission): Response
|
||||||
|
{
|
||||||
|
if ($request->routeIs('student.*')) {
|
||||||
|
$student = $this->currentStudent($request);
|
||||||
|
|
||||||
|
abort_if($submission->student_id !== $student->id, 403);
|
||||||
|
|
||||||
|
return Inertia::render('student/course-registrations/show', [
|
||||||
|
'submission' => $this->service->withDetails($submission),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('admin/manage/course-registrations/show', [
|
||||||
|
'submission' => $this->service->withDetails($submission),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(CourseRegistrationRequest $request): RedirectResponse
|
public function store(CourseRegistrationRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
if ($request->routeIs('student.*')) {
|
||||||
|
$student = $this->currentStudent($request);
|
||||||
|
$term = $this->academicTermService->getActive();
|
||||||
|
|
||||||
|
$submission = $this->service->sign(
|
||||||
|
$student,
|
||||||
|
$term,
|
||||||
|
$request->validated('course_class_ids'),
|
||||||
|
$request->file('signature'),
|
||||||
|
);
|
||||||
|
|
||||||
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditandatangani.']);
|
||||||
|
|
||||||
|
return to_route('student.course-registrations.show', $submission);
|
||||||
|
}
|
||||||
|
|
||||||
$this->service->create($request->validated());
|
$this->service->create($request->validated());
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']);
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil ditambahkan.']);
|
||||||
@ -50,19 +122,26 @@ public function store(CourseRegistrationRequest $request): RedirectResponse
|
|||||||
return to_route('admin.manage.course-registrations.index');
|
return to_route('admin.manage.course-registrations.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(CourseRegistrationRequest $request, CourseRegistration $courseRegistration): RedirectResponse
|
public function approve(CourseRegistrationSubmission $submission): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->service->update($courseRegistration, $request->validated());
|
$this->service->approve($submission);
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil diperbarui.']);
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil disetujui.'])->back();
|
||||||
|
|
||||||
return to_route('admin.manage.course-registrations.index');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(CourseRegistration $courseRegistration): RedirectResponse
|
public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->service->delete($courseRegistration);
|
$this->service->reject($submission, $request->validated('reason'));
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Registrasi KRS berhasil dihapus.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentStudent(Request $request): Student
|
||||||
|
{
|
||||||
|
$student = $request->user()->student;
|
||||||
|
|
||||||
|
abort_if(! $student, 403);
|
||||||
|
|
||||||
|
return $student;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ public function share(Request $request): array
|
|||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $request->user()?->load('roles:id,name'),
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
'unreadNotificationsCount' => fn () => $request->user()
|
'unreadNotificationsCount' => fn () => $request->user()
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Services\Admin\Manage\CourseRegistrationService;
|
||||||
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -10,40 +11,65 @@ class CourseRegistrationRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
|
if (! $this->routeIs('student.*')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$student = $this->user()->student;
|
||||||
|
|
||||||
|
if (! $student) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$term = app(AcademicTermService::class)->getActive();
|
||||||
|
|
||||||
|
if (! $term) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(CourseRegistrationService::class);
|
||||||
|
$submission = $service->currentSubmission($student, $term);
|
||||||
|
|
||||||
|
if (! $service->canSubmit($submission)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $service->paymentStatus($student, $term)['is_paid'];
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$registration = $this->route('course_registration');
|
if ($this->routeIs('student.*')) {
|
||||||
|
$student = $this->user()->student;
|
||||||
|
$term = app(AcademicTermService::class)->getActive();
|
||||||
|
$allowedIds = $student && $term
|
||||||
|
? app(CourseRegistrationService::class)->allowedCourseClassIds($student, $term)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'course_class_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'course_class_ids.*' => ['integer', Rule::in($allowedIds)],
|
||||||
|
'signature' => ['required', 'image', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
||||||
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
||||||
'course_class_id' => [
|
'course_class_ids' => ['required', 'array', 'min:1'],
|
||||||
'required',
|
'course_class_ids.*' => [
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('course_classes', 'id'),
|
Rule::exists('course_classes', 'id'),
|
||||||
Rule::unique('course_registrations')
|
Rule::unique('course_registrations', 'course_class_id')
|
||||||
->where('student_id', $this->input('student_id'))
|
->where('student_id', $this->input('student_id')),
|
||||||
->ignore($registration?->id),
|
|
||||||
],
|
],
|
||||||
'status' => ['nullable', 'string', Rule::in(RegistrationStatus::values())],
|
|
||||||
'approved_by' => ['nullable', 'integer', Rule::exists('lecturers', 'id')],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'course_class_id' => 'kelas mata kuliah',
|
'course_class_ids' => 'mata kuliah',
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'course_class_id.unique' => 'Mahasiswa ini sudah terdaftar pada kelas mata kuliah tersebut.',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class RejectCourseRegistrationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reason' => ['required', 'string', 'max:1000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reason.required' => 'Alasan penolakan wajib diisi.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -13,13 +12,6 @@ class CourseRegistration extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'status' => RegistrationStatus::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function student(): BelongsTo
|
public function student(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Student::class);
|
return $this->belongsTo(Student::class);
|
||||||
@ -35,8 +27,8 @@ public function courseClass(): BelongsTo
|
|||||||
return $this->belongsTo(CourseClass::class);
|
return $this->belongsTo(CourseClass::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function approver(): BelongsTo
|
public function submission(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Lecturer::class, 'approved_by');
|
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/Models/CourseRegistrationSubmission.php
Normal file
66
app/Models/CourseRegistrationSubmission.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\RegistrationStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
|
#[Guarded(['id'])]
|
||||||
|
#[Appends(['signature_url'])]
|
||||||
|
class CourseRegistrationSubmission extends Model implements HasMedia
|
||||||
|
{
|
||||||
|
use InteractsWithMedia;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => RegistrationStatus::class,
|
||||||
|
'signed_at' => 'datetime',
|
||||||
|
'reviewed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerMediaCollections(): void
|
||||||
|
{
|
||||||
|
$this->addMediaCollection('signature')->singleFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function student(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Student::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function academicTerm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademicTerm::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviewer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reviewed_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function courseRegistrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseRegistration::class, 'submission_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseRegistrationSubmissionLog::class, 'submission_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function signatureUrl(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => $this->getFirstMediaUrl('signature') ?: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Models/CourseRegistrationSubmissionLog.php
Normal file
29
app/Models/CourseRegistrationSubmissionLog.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\RegistrationStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Guarded(['id'])]
|
||||||
|
class CourseRegistrationSubmissionLog extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => RegistrationStatus::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submission(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'actor_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,6 +62,11 @@ public function courseRegistrations(): HasMany
|
|||||||
return $this->hasMany(CourseRegistration::class);
|
return $this->hasMany(CourseRegistration::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function courseRegistrationSubmissions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseRegistrationSubmission::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function letterRequests(): HasMany
|
public function letterRequests(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(LetterRequest::class);
|
return $this->hasMany(LetterRequest::class);
|
||||||
|
|||||||
@ -10,8 +10,8 @@ class CourseClassService
|
|||||||
{
|
{
|
||||||
public function getAllForSelect(): Collection
|
public function getAllForSelect(): Collection
|
||||||
{
|
{
|
||||||
return CourseClass::select(['id', 'course_id'])
|
return CourseClass::select(['id', 'course_id', 'academic_term_id'])
|
||||||
->with('course:id,code,name')
|
->with('course:id,code,name,semester_number,department_id')
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,22 +3,33 @@
|
|||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage;
|
||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
|
use App\Models\AcademicTerm;
|
||||||
use App\Models\ClassEnrollment;
|
use App\Models\ClassEnrollment;
|
||||||
|
use App\Models\Course;
|
||||||
|
use App\Models\CourseClass;
|
||||||
use App\Models\CourseRegistration;
|
use App\Models\CourseRegistration;
|
||||||
|
use App\Models\CourseRegistrationSubmission;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Models\TuitionInvoice;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CourseRegistrationService
|
class CourseRegistrationService
|
||||||
{
|
{
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return CourseRegistration::query()
|
return CourseRegistrationSubmission::query()
|
||||||
->select(['id', 'student_id', 'academic_term_id', 'course_class_id', 'status', 'approved_by', 'created_at'])
|
->select(['id', 'student_id', 'academic_term_id', 'status', 'signed_at', 'rejection_reason', 'reviewed_by', 'reviewed_at'])
|
||||||
->with([
|
->with([
|
||||||
'student.user.profile',
|
'student.user.profile',
|
||||||
'student.department',
|
'student.department',
|
||||||
'academicTerm:id,name,semester,start_date,end_date',
|
'academicTerm:id,name,semester,start_date,end_date',
|
||||||
'courseClass.course:id,code,name',
|
'reviewer.profile',
|
||||||
'approver.user.profile',
|
'courseRegistrations.courseClass.course:id,code,name',
|
||||||
|
'logs' => fn ($q) => $q->latest(),
|
||||||
|
'logs.actor.profile',
|
||||||
])
|
])
|
||||||
->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
|
->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}%"))))
|
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
|
||||||
@ -28,46 +39,63 @@ public function paginated(int $perPage = 25, string $search = '', ?string $statu
|
|||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): CourseRegistration
|
/**
|
||||||
|
* Admin-created registration: additively attach course classes to a
|
||||||
|
* student's submission for a term, without disturbing an existing
|
||||||
|
* submission's review status.
|
||||||
|
*
|
||||||
|
* @param array{student_id: int, academic_term_id: int, course_class_ids: array<int, int>} $data
|
||||||
|
*/
|
||||||
|
public function create(array $data): CourseRegistrationSubmission
|
||||||
{
|
{
|
||||||
$registration = CourseRegistration::create([
|
return DB::transaction(function () use ($data) {
|
||||||
|
$submission = CourseRegistrationSubmission::firstOrCreate(
|
||||||
|
[
|
||||||
'student_id' => $data['student_id'],
|
'student_id' => $data['student_id'],
|
||||||
'academic_term_id' => $data['academic_term_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,
|
'status' => RegistrationStatus::Submitted,
|
||||||
|
'signed_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($submission->wasRecentlyCreated) {
|
||||||
|
$submission->logs()->create([
|
||||||
|
'status' => RegistrationStatus::Submitted,
|
||||||
|
'actor_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['course_class_ids'] as $courseClassId) {
|
||||||
|
CourseRegistration::create([
|
||||||
|
'student_id' => $data['student_id'],
|
||||||
|
'academic_term_id' => $data['academic_term_id'],
|
||||||
|
'course_class_id' => $courseClassId,
|
||||||
|
'submission_id' => $submission->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $submission;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($submission) {
|
||||||
|
$submission->update([
|
||||||
|
'status' => RegistrationStatus::Approved,
|
||||||
|
'rejection_reason' => null,
|
||||||
|
'reviewed_by' => auth()->id(),
|
||||||
|
'reviewed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncEnrollment($registration);
|
$submission->logs()->create([
|
||||||
|
'status' => RegistrationStatus::Approved,
|
||||||
return $registration;
|
'actor_id' => auth()->id(),
|
||||||
}
|
]);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
foreach ($submission->courseRegistrations as $registration) {
|
||||||
ClassEnrollment::firstOrCreate(
|
ClassEnrollment::firstOrCreate(
|
||||||
[
|
[
|
||||||
'course_class_id' => $registration->course_class_id,
|
'course_class_id' => $registration->course_class_id,
|
||||||
@ -78,4 +106,188 @@ private function syncEnrollment(CourseRegistration $registration): void
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $submission;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(CourseRegistrationSubmission $submission, string $reason): CourseRegistrationSubmission
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($submission, $reason) {
|
||||||
|
$submission->update([
|
||||||
|
'status' => RegistrationStatus::Rejected,
|
||||||
|
'rejection_reason' => $reason,
|
||||||
|
'reviewed_by' => auth()->id(),
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$submission->logs()->create([
|
||||||
|
'status' => RegistrationStatus::Rejected,
|
||||||
|
'reason' => $reason,
|
||||||
|
'actor_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $submission;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{course: Course, course_class: ?CourseClass}>
|
||||||
|
*/
|
||||||
|
public function availableCourseClasses(Student $student, AcademicTerm $term): Collection
|
||||||
|
{
|
||||||
|
$courses = Course::query()
|
||||||
|
->where('department_id', $student->department_id)
|
||||||
|
->where('semester_number', $student->current_semester)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'code', 'name', 'credits']);
|
||||||
|
|
||||||
|
$classes = CourseClass::query()
|
||||||
|
->whereIn('course_id', $courses->pluck('id'))
|
||||||
|
->where('academic_term_id', $term->id)
|
||||||
|
->with('lecturer.user.profile')
|
||||||
|
->get()
|
||||||
|
->keyBy('course_id');
|
||||||
|
|
||||||
|
return $courses->map(fn (Course $course) => [
|
||||||
|
'course' => $course,
|
||||||
|
'course_class' => $classes->get($course->id),
|
||||||
|
])->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{has_invoice: bool, is_paid: bool, amount_due: ?float, paid_total: ?float}
|
||||||
|
*/
|
||||||
|
public function paymentStatus(Student $student, AcademicTerm $term): array
|
||||||
|
{
|
||||||
|
$invoice = TuitionInvoice::query()
|
||||||
|
->where('student_id', $student->id)
|
||||||
|
->where('academic_term_id', $term->id)
|
||||||
|
->withSum('payments as paid_total', 'amount_paid')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $invoice) {
|
||||||
|
return [
|
||||||
|
'has_invoice' => false,
|
||||||
|
'is_paid' => false,
|
||||||
|
'amount_due' => null,
|
||||||
|
'paid_total' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$paidTotal = (float) ($invoice->paid_total ?? 0);
|
||||||
|
$amountDue = (float) $invoice->amount_due;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'has_invoice' => true,
|
||||||
|
'is_paid' => $paidTotal >= $amountDue,
|
||||||
|
'amount_due' => $amountDue,
|
||||||
|
'paid_total' => $paidTotal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentSubmission(Student $student, AcademicTerm $term): ?CourseRegistrationSubmission
|
||||||
|
{
|
||||||
|
return CourseRegistrationSubmission::query()
|
||||||
|
->where('student_id', $student->id)
|
||||||
|
->where('academic_term_id', $term->id)
|
||||||
|
->with([
|
||||||
|
'courseRegistrations.courseClass.course',
|
||||||
|
'courseRegistrations.courseClass.lecturer.user.profile',
|
||||||
|
'logs' => fn ($q) => $q->latest(),
|
||||||
|
'logs.actor.profile',
|
||||||
|
])
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, CourseRegistrationSubmission>
|
||||||
|
*/
|
||||||
|
public function submissionsFor(Student $student): Collection
|
||||||
|
{
|
||||||
|
return CourseRegistrationSubmission::query()
|
||||||
|
->where('student_id', $student->id)
|
||||||
|
->withCount('courseRegistrations')
|
||||||
|
->with('academicTerm:id,name,semester,start_date,end_date')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withDetails(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
|
||||||
|
{
|
||||||
|
return $submission->load([
|
||||||
|
'student.user.profile',
|
||||||
|
'student.department',
|
||||||
|
'academicTerm:id,name,semester,start_date,end_date',
|
||||||
|
'reviewer.profile',
|
||||||
|
'courseRegistrations.courseClass.course',
|
||||||
|
'courseRegistrations.courseClass.lecturer.user.profile',
|
||||||
|
'logs' => fn ($q) => $q->oldest(),
|
||||||
|
'logs.actor.profile',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSubmit(?CourseRegistrationSubmission $submission): bool
|
||||||
|
{
|
||||||
|
return $submission === null || $submission->status === RegistrationStatus::Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
public function allowedCourseClassIds(Student $student, AcademicTerm $term): array
|
||||||
|
{
|
||||||
|
$courseIds = Course::query()
|
||||||
|
->where('department_id', $student->department_id)
|
||||||
|
->where('semester_number', $student->current_semester)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
return CourseClass::query()
|
||||||
|
->whereIn('course_id', $courseIds)
|
||||||
|
->where('academic_term_id', $term->id)
|
||||||
|
->pluck('id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Student self-service sign-off: replaces the submission's course
|
||||||
|
* selection wholesale and attaches the captured signature.
|
||||||
|
*
|
||||||
|
* @param array<int, int> $courseClassIds
|
||||||
|
*/
|
||||||
|
public function sign(Student $student, AcademicTerm $term, array $courseClassIds, UploadedFile $signatureFile): CourseRegistrationSubmission
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($student, $term, $courseClassIds, $signatureFile) {
|
||||||
|
$submission = CourseRegistrationSubmission::updateOrCreate(
|
||||||
|
['student_id' => $student->id, 'academic_term_id' => $term->id],
|
||||||
|
[
|
||||||
|
'status' => RegistrationStatus::Submitted,
|
||||||
|
'signed_at' => now(),
|
||||||
|
'rejection_reason' => null,
|
||||||
|
'reviewed_by' => null,
|
||||||
|
'reviewed_at' => null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$submission->courseRegistrations()->delete();
|
||||||
|
|
||||||
|
foreach ($courseClassIds as $courseClassId) {
|
||||||
|
CourseRegistration::create([
|
||||||
|
'student_id' => $student->id,
|
||||||
|
'academic_term_id' => $term->id,
|
||||||
|
'course_class_id' => $courseClassId,
|
||||||
|
'submission_id' => $submission->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$submission->addMedia($signatureFile)->toMediaCollection('signature');
|
||||||
|
|
||||||
|
$submission->logs()->create([
|
||||||
|
'status' => RegistrationStatus::Submitted,
|
||||||
|
'actor_id' => $student->user_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $submission;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Spatie\Permission\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@ -22,6 +23,10 @@
|
|||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$middleware->alias([
|
||||||
|
'role' => RoleMiddleware::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?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_registration_submissions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->enum('status', RegistrationStatus::values())->default(RegistrationStatus::Submitted->value);
|
||||||
|
$table->timestamp('signed_at');
|
||||||
|
$table->text('rejection_reason')->nullable();
|
||||||
|
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('reviewed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['student_id', 'academic_term_id'], 'course_reg_submissions_student_term_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('course_registration_submissions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -14,8 +13,7 @@ public function up(): void
|
|||||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||||
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('academic_term_id')->constrained()->cascadeOnDelete();
|
||||||
$table->foreignId('course_class_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('course_class_id')->constrained()->cascadeOnDelete();
|
||||||
$table->enum('status', RegistrationStatus::values())->nullable()->default(RegistrationStatus::Submitted->value);
|
$table->foreignId('submission_id')->constrained('course_registration_submissions')->cascadeOnDelete();
|
||||||
$table->foreignId('approved_by')->nullable()->constrained('lecturers')->nullOnDelete();
|
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->unique(['student_id', 'course_class_id']);
|
$table->unique(['student_id', 'course_class_id']);
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?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_registration_submission_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('submission_id')->constrained('course_registration_submissions')->cascadeOnDelete();
|
||||||
|
$table->enum('status', RegistrationStatus::values());
|
||||||
|
$table->text('reason')->nullable();
|
||||||
|
$table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('course_registration_submission_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -2,32 +2,80 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Enums\RegistrationStatus;
|
||||||
use App\Models\CourseRegistration;
|
use App\Models\CourseRegistration;
|
||||||
|
use App\Models\CourseRegistrationSubmission;
|
||||||
|
use App\Models\CourseRegistrationSubmissionLog;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class CourseRegistrationSeeder extends Seeder
|
class CourseRegistrationSeeder extends Seeder
|
||||||
{
|
{
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
CourseRegistration::insert([
|
$approved = CourseRegistrationSubmission::create([
|
||||||
[
|
'student_id' => 1,
|
||||||
|
'academic_term_id' => 2,
|
||||||
|
'status' => RegistrationStatus::Approved,
|
||||||
|
'signed_at' => '2026-01-20 08:00:00',
|
||||||
|
'reviewed_by' => 1,
|
||||||
|
'reviewed_at' => '2026-01-20 13:00:00',
|
||||||
|
'created_at' => '2026-01-20 08:00:00',
|
||||||
|
'updated_at' => '2026-01-20 13:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
CourseRegistration::create([
|
||||||
'student_id' => 1,
|
'student_id' => 1,
|
||||||
'academic_term_id' => 2,
|
'academic_term_id' => 2,
|
||||||
'course_class_id' => 1,
|
'course_class_id' => 1,
|
||||||
'status' => 'approved',
|
'submission_id' => $approved->id,
|
||||||
'approved_by' => 1,
|
|
||||||
'created_at' => '2026-01-20 08:00:00',
|
'created_at' => '2026-01-20 08:00:00',
|
||||||
'updated_at' => '2026-01-20 13:00:00',
|
'updated_at' => '2026-01-20 08:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
CourseRegistrationSubmissionLog::insert([
|
||||||
|
[
|
||||||
|
'submission_id' => $approved->id,
|
||||||
|
'status' => RegistrationStatus::Submitted->value,
|
||||||
|
'reason' => null,
|
||||||
|
'actor_id' => 1,
|
||||||
|
'created_at' => '2026-01-20 08:00:00',
|
||||||
|
'updated_at' => '2026-01-20 08:00:00',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
'submission_id' => $approved->id,
|
||||||
|
'status' => RegistrationStatus::Approved->value,
|
||||||
|
'reason' => null,
|
||||||
|
'actor_id' => 1,
|
||||||
|
'created_at' => '2026-01-20 13:00:00',
|
||||||
|
'updated_at' => '2026-01-20 13:00:00',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$submitted = CourseRegistrationSubmission::create([
|
||||||
|
'student_id' => 2,
|
||||||
|
'academic_term_id' => 2,
|
||||||
|
'status' => RegistrationStatus::Submitted,
|
||||||
|
'signed_at' => '2026-01-20 08:10:00',
|
||||||
|
'created_at' => '2026-01-20 08:10:00',
|
||||||
|
'updated_at' => '2026-01-20 08:10:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
CourseRegistration::create([
|
||||||
'student_id' => 2,
|
'student_id' => 2,
|
||||||
'academic_term_id' => 2,
|
'academic_term_id' => 2,
|
||||||
'course_class_id' => 2,
|
'course_class_id' => 2,
|
||||||
'status' => 'submitted',
|
'submission_id' => $submitted->id,
|
||||||
'approved_by' => null,
|
'created_at' => '2026-01-20 08:10:00',
|
||||||
|
'updated_at' => '2026-01-20 08:10:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
CourseRegistrationSubmissionLog::create([
|
||||||
|
'submission_id' => $submitted->id,
|
||||||
|
'status' => RegistrationStatus::Submitted->value,
|
||||||
|
'reason' => null,
|
||||||
|
'actor_id' => 2,
|
||||||
'created_at' => '2026-01-20 08:10:00',
|
'created_at' => '2026-01-20 08:10:00',
|
||||||
'updated_at' => '2026-01-20 08:10:00',
|
'updated_at' => '2026-01-20 08:10:00',
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
docs/reference/KRS.xlsx
Normal file
BIN
docs/reference/KRS.xlsx
Normal file
Binary file not shown.
49
package-lock.json
generated
49
package-lock.json
generated
@ -54,6 +54,7 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-signature-canvas": "^1.0.7",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
"shadcn": "^4.16.1",
|
"shadcn": "^4.16.1",
|
||||||
"sonner": "^2.0.0",
|
"sonner": "^2.0.0",
|
||||||
@ -70,6 +71,7 @@
|
|||||||
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
|
"@types/react-signature-canvas": "^1.0.7",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@ -4335,6 +4337,24 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-signature-canvas": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-signature-canvas/-/react-signature-canvas-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-0ulzaUvcIQ0HdNB5fHj+KE7ztWhlhYRsi65TdPIRj/t+FD5Rr8NJKBv4/xLViz7HsUh/tgqsoyKeARrm9+gPIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/signature_pad": "<3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/signature_pad": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@ -9137,7 +9157,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@ -10231,7 +10250,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@ -10674,6 +10692,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-signature-canvas": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-yo0x0uTMVmcClaqryuQu6F8xEVapk5zdM9/nuQ7GalDJWccoVNZPMmCFGK7U5r3I0mrEHPB9E5/rFSYUgZcfmA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"signature_pad": "^2.3.2",
|
||||||
|
"trim-canvas": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prop-types": "^15.5.8",
|
||||||
|
"react": "0.14 - 19",
|
||||||
|
"react-dom": "0.14 - 19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@ -11383,6 +11416,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/signature_pad": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sisteransi": {
|
"node_modules/sisteransi": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||||
@ -11809,6 +11848,12 @@
|
|||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/trim-canvas": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
|
"@types/react-signature-canvas": "^1.0.7",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@ -78,6 +79,7 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-signature-canvas": "^1.0.7",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
"shadcn": "^4.16.1",
|
"shadcn": "^4.16.1",
|
||||||
"sonner": "^2.0.0",
|
"sonner": "^2.0.0",
|
||||||
|
|||||||
97
resources/js/components/signature-pad.tsx
Normal file
97
resources/js/components/signature-pad.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { Eraser } from 'lucide-react';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import SignatureCanvasImport from 'react-signature-canvas';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
// react-signature-canvas ships as a CommonJS/UMD bundle. Vite's dev-server
|
||||||
|
// pre-bundling wraps its `module.exports` (itself `{ __esModule: true,
|
||||||
|
// default: SignatureCanvas }`) as the ESM default export, so the default
|
||||||
|
// import resolves one layer too shallow. Unwrap it before use.
|
||||||
|
const SignatureCanvas = ((
|
||||||
|
SignatureCanvasImport as unknown as {
|
||||||
|
default?: typeof SignatureCanvasImport;
|
||||||
|
}
|
||||||
|
).default ?? SignatureCanvasImport) as typeof SignatureCanvasImport;
|
||||||
|
|
||||||
|
type SignaturePadProps = {
|
||||||
|
name: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SignaturePad({ name, error }: SignaturePadProps) {
|
||||||
|
const padRef = useRef<SignatureCanvasImport>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function syncFileInput() {
|
||||||
|
const pad = padRef.current;
|
||||||
|
const input = fileInputRef.current;
|
||||||
|
|
||||||
|
if (!pad || !input || pad.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pad.getTrimmedCanvas().toBlob((blob: Blob | null) => {
|
||||||
|
if (!blob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = new File([blob], 'signature.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
});
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
input.files = dataTransfer.files;
|
||||||
|
}, 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
padRef.current?.clear();
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>
|
||||||
|
Tanda Tangan <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-md border bg-white">
|
||||||
|
<SignatureCanvas
|
||||||
|
ref={padRef}
|
||||||
|
penColor="#0f172a"
|
||||||
|
canvasProps={{
|
||||||
|
className:
|
||||||
|
'h-[180px] w-full cursor-crosshair touch-none',
|
||||||
|
}}
|
||||||
|
onEnd={syncFileInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
name={name}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Gambar tanda tangan Anda pada kotak di atas menggunakan mouse
|
||||||
|
atau layar sentuh.
|
||||||
|
</p>
|
||||||
|
<InputError message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,30 +1,26 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { format } from 'date-fns';
|
import { Check, Eye, X } from 'lucide-react';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
import { show } from '@/routes/admin/manage/course-registrations';
|
||||||
import type {
|
import type { CourseRegistrationSubmission } from '@/types/course-registration';
|
||||||
CourseRegistration,
|
|
||||||
RegistrationStatus,
|
|
||||||
} from '@/types/course-registration';
|
|
||||||
import { RegistrationStatusLabels } from '@/types/course-registration';
|
import { RegistrationStatusLabels } from '@/types/course-registration';
|
||||||
|
|
||||||
export type { CourseRegistration } from '@/types/course-registration';
|
export type { CourseRegistrationSubmission } from '@/types/course-registration';
|
||||||
|
|
||||||
type CreateColumnsParams = {
|
type CreateColumnsParams = {
|
||||||
handleEdit: (registration: CourseRegistration) => void;
|
handleApprove: (submission: CourseRegistrationSubmission) => void;
|
||||||
handleDeleteClick: (registration: CourseRegistration) => void;
|
handleRejectClick: (submission: CourseRegistrationSubmission) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createCourseRegistrationColumns(
|
export function createCourseRegistrationColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<CourseRegistration>[] {
|
): ColumnDef<CourseRegistrationSubmission>[] {
|
||||||
const { handleEdit, handleDeleteClick } = params;
|
const { handleApprove, handleRejectClick } = params;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'student.student_number',
|
id: 'student',
|
||||||
header: () => <span>Mahasiswa</span>,
|
header: () => <span>Mahasiswa</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const student = row.original.student;
|
const student = row.original.student;
|
||||||
@ -43,101 +39,86 @@ export function createCourseRegistrationColumns(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'course_class.course.name',
|
id: 'semester',
|
||||||
header: () => <span>Kelas Mata Kuliah</span>,
|
header: () => <span className="block text-center">Semester</span>,
|
||||||
cell: ({ row }) => {
|
meta: {
|
||||||
const courseClass = row.original.course_class;
|
className: 'w-[100px] text-center',
|
||||||
|
headerClassName: 'w-[100px] text-center',
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">
|
|
||||||
{courseClass?.course?.name ?? 'N/A'}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{courseClass?.course?.code}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
cell: ({ row }) => row.original.student?.current_semester ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'academic_term.name',
|
id: 'count',
|
||||||
header: () => <span>Periode</span>,
|
header: () => <span className="block text-center">Jumlah</span>,
|
||||||
cell: ({ row }) => {
|
meta: {
|
||||||
const term = row.original.academic_term;
|
className: 'w-[100px] text-center',
|
||||||
|
headerClassName: 'w-[100px] text-center',
|
||||||
return term ? formatAcademicTermLabel(term) : '-';
|
|
||||||
},
|
},
|
||||||
|
cell: ({ row }) => row.original.course_registrations.length,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: () => <span className="block text-center">Status</span>,
|
header: () => <span className="block text-center">Status</span>,
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[130px] text-center',
|
className: 'w-[140px] text-center',
|
||||||
headerClassName: 'w-[130px] text-center',
|
headerClassName: 'w-[140px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.getValue(
|
const status = row.original.status;
|
||||||
'status',
|
|
||||||
) as RegistrationStatus | null;
|
|
||||||
|
|
||||||
const variant =
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
status === 'approved'
|
status === 'approved'
|
||||||
? 'default'
|
? 'default'
|
||||||
: status === 'rejected'
|
: status === 'rejected'
|
||||||
? 'destructive'
|
? 'destructive'
|
||||||
: 'outline';
|
: 'outline'
|
||||||
|
}
|
||||||
return (
|
>
|
||||||
<div className="flex justify-center">
|
|
||||||
{status ? (
|
|
||||||
<Badge variant={variant}>
|
|
||||||
{RegistrationStatusLabels[status]}
|
{RegistrationStatusLabels[status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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',
|
id: 'actions',
|
||||||
header: () => <span className="block text-center">Aksi</span>,
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[100px] text-center',
|
className: 'w-[130px] text-center',
|
||||||
headerClassName: 'w-[100px] text-center',
|
headerClassName: 'w-[130px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
|
const submission = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Lihat Detail',
|
||||||
icon: <Pencil className="h-4 w-4" />,
|
icon: <Eye className="h-4 w-4" />,
|
||||||
onClick: () => handleEdit(row.original),
|
href: show.url(submission.id),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hapus',
|
label: 'Setujui',
|
||||||
icon: (
|
icon: <Check className="h-4 w-4" />,
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
iconClassName: 'text-primary',
|
||||||
),
|
show: submission.status === 'submitted',
|
||||||
onClick: () => handleDeleteClick(row.original),
|
onClick: () => handleApprove(submission),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tolak',
|
||||||
|
icon: <X className="h-4 w-4" />,
|
||||||
|
iconClassName: 'text-destructive',
|
||||||
|
show: submission.status === 'submitted',
|
||||||
|
onClick: () => handleRejectClick(submission),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,21 @@ import { Plus } from 'lucide-react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
|
||||||
import type { FilterField } from '@/components/filter-dialog';
|
import type { FilterField } from '@/components/filter-dialog';
|
||||||
import { FilterDialog } from '@/components/filter-dialog';
|
import { FilterDialog } from '@/components/filter-dialog';
|
||||||
import { FormDialog } from '@/components/form-dialog';
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
} from '@/components/ui/combobox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -18,18 +26,19 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
index as courseRegistrationIndex,
|
index as courseRegistrationIndex,
|
||||||
destroy,
|
approve,
|
||||||
|
reject,
|
||||||
store,
|
store,
|
||||||
update,
|
|
||||||
} from '@/routes/admin/manage/course-registrations';
|
} from '@/routes/admin/manage/course-registrations';
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
import type {
|
import type {
|
||||||
CourseRegistration,
|
|
||||||
CourseRegistrationCourseClass,
|
CourseRegistrationCourseClass,
|
||||||
CourseRegistrationStudent,
|
CourseRegistrationStudent,
|
||||||
|
CourseRegistrationSubmission,
|
||||||
} from '@/types/course-registration';
|
} from '@/types/course-registration';
|
||||||
import {
|
import {
|
||||||
RegistrationStatuses,
|
RegistrationStatuses,
|
||||||
@ -37,16 +46,16 @@ import {
|
|||||||
} from '@/types/course-registration';
|
} from '@/types/course-registration';
|
||||||
import { createCourseRegistrationColumns } from './columns';
|
import { createCourseRegistrationColumns } from './columns';
|
||||||
|
|
||||||
type AcademicTermOption = { id: number; name: string; semester: string };
|
type AcademicTermOption = {
|
||||||
type LecturerOption = {
|
|
||||||
id: number;
|
id: number;
|
||||||
lecturer_number: string;
|
name: string;
|
||||||
user: { profile: { full_name: string } | null } | null;
|
semester: string;
|
||||||
|
is_active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
registrations: {
|
registrations: {
|
||||||
data: CourseRegistration[];
|
data: CourseRegistrationSubmission[];
|
||||||
current_page: number;
|
current_page: number;
|
||||||
last_page: number;
|
last_page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
@ -55,7 +64,6 @@ type Props = {
|
|||||||
students: CourseRegistrationStudent[];
|
students: CourseRegistrationStudent[];
|
||||||
academicTerms: AcademicTermOption[];
|
academicTerms: AcademicTermOption[];
|
||||||
courseClasses: CourseRegistrationCourseClass[];
|
courseClasses: CourseRegistrationCourseClass[];
|
||||||
lecturers: LecturerOption[];
|
|
||||||
highlight?: number;
|
highlight?: number;
|
||||||
filters: {
|
filters: {
|
||||||
status?: string;
|
status?: string;
|
||||||
@ -73,22 +81,17 @@ function courseClassLabel(courseClass: CourseRegistrationCourseClass): string {
|
|||||||
return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}`;
|
return `${course?.code ?? '-'} - ${course?.name ?? 'N/A'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lecturerLabel(lecturer: LecturerOption): string {
|
|
||||||
return `${lecturer.user?.profile?.full_name ?? 'N/A'} - ${lecturer.lecturer_number}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CourseRegistrationIndex({
|
export default function CourseRegistrationIndex({
|
||||||
registrations,
|
registrations,
|
||||||
students,
|
students,
|
||||||
academicTerms,
|
academicTerms,
|
||||||
courseClasses,
|
courseClasses,
|
||||||
lecturers,
|
|
||||||
highlight,
|
highlight,
|
||||||
filters,
|
filters,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<CourseRegistration | null>(null);
|
const [rejecting, setRejecting] =
|
||||||
const [deleting, setDeleting] = useState<CourseRegistration | null>(null);
|
useState<CourseRegistrationSubmission | null>(null);
|
||||||
|
|
||||||
const filterFields: FilterField[] = [
|
const filterFields: FilterField[] = [
|
||||||
{
|
{
|
||||||
@ -128,19 +131,13 @@ export default function CourseRegistrationIndex({
|
|||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleDelete() {
|
function handleApprove(submission: CourseRegistrationSubmission) {
|
||||||
if (!deleting) {
|
router.patch(approve(submission.id));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.delete(destroy(deleting.id), {
|
|
||||||
onSuccess: () => setDeleting(null),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = createCourseRegistrationColumns({
|
const columns = createCourseRegistrationColumns({
|
||||||
handleEdit: (registration) => setEditing(registration),
|
handleApprove,
|
||||||
handleDeleteClick: (registration) => setDeleting(registration),
|
handleRejectClick: (submission) => setRejecting(submission),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -176,22 +173,6 @@ export default function CourseRegistrationIndex({
|
|||||||
students={students}
|
students={students}
|
||||||
academicTerms={academicTerms}
|
academicTerms={academicTerms}
|
||||||
courseClasses={courseClasses}
|
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
|
<DataTable
|
||||||
@ -214,106 +195,162 @@ export default function CourseRegistrationIndex({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<RejectForm
|
||||||
target={deleting}
|
open={rejecting !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setRejecting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Registrasi"
|
submission={rejecting}
|
||||||
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RejectForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
submission,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
submission: CourseRegistrationSubmission | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Tolak Registrasi KRS"
|
||||||
|
action={submission ? reject(submission.id) : ''}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{({ errors }) => (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
KRS milik{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{submission?.student?.user?.profile?.full_name ??
|
||||||
|
'mahasiswa ini'}
|
||||||
|
</span>{' '}
|
||||||
|
akan ditolak. Mahasiswa perlu memperbaiki dan mengajukan
|
||||||
|
ulang.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="reason">
|
||||||
|
Alasan Penolakan{' '}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reason"
|
||||||
|
name="reason"
|
||||||
|
placeholder="Jelaskan alasan penolakan"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.reason} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RegistrationFields({
|
function RegistrationFields({
|
||||||
errors,
|
errors,
|
||||||
editing,
|
|
||||||
students,
|
students,
|
||||||
academicTerms,
|
academicTerms,
|
||||||
courseClasses,
|
courseClasses,
|
||||||
lecturers,
|
|
||||||
}: {
|
}: {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
editing?: CourseRegistration;
|
|
||||||
students: CourseRegistrationStudent[];
|
students: CourseRegistrationStudent[];
|
||||||
academicTerms: AcademicTermOption[];
|
academicTerms: AcademicTermOption[];
|
||||||
courseClasses: CourseRegistrationCourseClass[];
|
courseClasses: CourseRegistrationCourseClass[];
|
||||||
lecturers: LecturerOption[];
|
|
||||||
}) {
|
}) {
|
||||||
|
const activeTermId = academicTerms.find((term) => term.is_active)?.id;
|
||||||
|
|
||||||
|
const [student, setStudent] = useState<CourseRegistrationStudent | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [academicTermId, setAcademicTermId] = useState(() =>
|
||||||
|
activeTermId ? String(activeTermId) : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableCourseClasses = courseClasses.filter(
|
||||||
|
(courseClass) =>
|
||||||
|
student &&
|
||||||
|
academicTermId &&
|
||||||
|
courseClass.academic_term_id === Number(academicTermId) &&
|
||||||
|
courseClass.course?.semester_number === student.current_semester &&
|
||||||
|
courseClass.course?.department_id === student.department?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectionKey = `${student?.id ?? ''}-${academicTermId}`;
|
||||||
|
const [selected, setSelected] = useState<number[]>([]);
|
||||||
|
const [selectedForKey, setSelectedForKey] = useState(selectionKey);
|
||||||
|
|
||||||
|
if (selectionKey !== selectedForKey) {
|
||||||
|
setSelectedForKey(selectionKey);
|
||||||
|
setSelected([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(courseClassId: number, checked: boolean) {
|
||||||
|
setSelected((prev) =>
|
||||||
|
checked
|
||||||
|
? [...prev, courseClassId]
|
||||||
|
: prev.filter((id) => id !== courseClassId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Mahasiswa <span className="text-destructive">*</span>
|
Mahasiswa <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
{!editing && <input type="hidden" name="student_id" />}
|
<input
|
||||||
<Select
|
type="hidden"
|
||||||
name="student_id"
|
name="student_id"
|
||||||
defaultValue={
|
value={student?.id ?? ''}
|
||||||
editing ? String(editing.student_id) : undefined
|
/>
|
||||||
}
|
<Combobox
|
||||||
|
items={students}
|
||||||
|
value={student}
|
||||||
|
onValueChange={setStudent}
|
||||||
|
itemToStringLabel={studentLabel}
|
||||||
|
isItemEqualToValue={(a, b) => a.id === b.id}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<ComboboxInput
|
||||||
<SelectValue placeholder="Pilih mahasiswa" />
|
placeholder="Pilih mahasiswa"
|
||||||
</SelectTrigger>
|
className="w-full"
|
||||||
<SelectContent>
|
/>
|
||||||
{students.map((student) => (
|
<ComboboxContent>
|
||||||
<SelectItem
|
<ComboboxEmpty>
|
||||||
key={student.id}
|
Mahasiswa tidak ditemukan.
|
||||||
value={String(student.id)}
|
</ComboboxEmpty>
|
||||||
>
|
<ComboboxList>
|
||||||
{studentLabel(student)}
|
{(option: CourseRegistrationStudent) => (
|
||||||
</SelectItem>
|
<ComboboxItem key={option.id} value={option}>
|
||||||
))}
|
{studentLabel(option)}
|
||||||
</SelectContent>
|
</ComboboxItem>
|
||||||
</Select>
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
<InputError message={errors.student_id} />
|
<InputError message={errors.student_id} />
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Periode Akademik <span className="text-destructive">*</span>
|
Periode Akademik <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
{!editing && <input type="hidden" name="academic_term_id" />}
|
<input
|
||||||
<Select
|
type="hidden"
|
||||||
name="academic_term_id"
|
name="academic_term_id"
|
||||||
defaultValue={
|
value={academicTermId}
|
||||||
editing ? String(editing.academic_term_id) : undefined
|
/>
|
||||||
}
|
<Select
|
||||||
|
value={academicTermId}
|
||||||
|
onValueChange={setAcademicTermId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Pilih periode akademik" />
|
<SelectValue placeholder="Pilih periode akademik" />
|
||||||
@ -329,51 +366,48 @@ function RegistrationFields({
|
|||||||
<InputError message={errors.academic_term_id} />
|
<InputError message={errors.academic_term_id} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Status</Label>
|
<Label>
|
||||||
<input type="hidden" name="status" />
|
Kelas Mata Kuliah{' '}
|
||||||
<Select
|
<span className="text-destructive">*</span>
|
||||||
name="status"
|
</Label>
|
||||||
defaultValue={editing?.status ?? 'submitted'}
|
{!student || !academicTermId ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pilih mahasiswa dan periode terlebih dahulu.
|
||||||
|
</p>
|
||||||
|
) : availableCourseClasses.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tidak ada kelas mata kuliah yang tersedia untuk semester
|
||||||
|
mahasiswa ini.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2">
|
||||||
|
{availableCourseClasses.map((courseClass) => (
|
||||||
|
<label
|
||||||
|
key={courseClass.id}
|
||||||
|
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<Checkbox
|
||||||
<SelectValue placeholder="Pilih status" />
|
checked={selected.includes(courseClass.id)}
|
||||||
</SelectTrigger>
|
onCheckedChange={(checked) =>
|
||||||
<SelectContent>
|
toggle(courseClass.id, checked === true)
|
||||||
{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">
|
<span className="text-sm">
|
||||||
<SelectValue placeholder="Pilih dosen (jika disetujui)" />
|
{courseClassLabel(courseClass)}
|
||||||
</SelectTrigger>
|
</span>
|
||||||
<SelectContent>
|
</label>
|
||||||
{lecturers.map((lecturer) => (
|
|
||||||
<SelectItem
|
|
||||||
key={lecturer.id}
|
|
||||||
value={String(lecturer.id)}
|
|
||||||
>
|
|
||||||
{lecturerLabel(lecturer)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
)}
|
||||||
<InputError message={errors.approved_by} />
|
{selected.map((courseClassId) => (
|
||||||
|
<input
|
||||||
|
key={courseClassId}
|
||||||
|
type="hidden"
|
||||||
|
name="course_class_ids[]"
|
||||||
|
value={courseClassId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<InputError message={errors.course_class_ids} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -385,14 +419,12 @@ function CreateForm({
|
|||||||
students,
|
students,
|
||||||
academicTerms,
|
academicTerms,
|
||||||
courseClasses,
|
courseClasses,
|
||||||
lecturers,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
students: CourseRegistrationStudent[];
|
students: CourseRegistrationStudent[];
|
||||||
academicTerms: AcademicTermOption[];
|
academicTerms: AcademicTermOption[];
|
||||||
courseClasses: CourseRegistrationCourseClass[];
|
courseClasses: CourseRegistrationCourseClass[];
|
||||||
lecturers: LecturerOption[];
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -410,54 +442,9 @@ function CreateForm({
|
|||||||
students={students}
|
students={students}
|
||||||
academicTerms={academicTerms}
|
academicTerms={academicTerms}
|
||||||
courseClasses={courseClasses}
|
courseClasses={courseClasses}
|
||||||
lecturers={lecturers}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
184
resources/js/pages/admin/manage/course-registrations/show.tsx
Normal file
184
resources/js/pages/admin/manage/course-registrations/show.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { Head, Link } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { index as courseRegistrationIndex } from '@/routes/admin/manage/course-registrations';
|
||||||
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
|
import type { CourseRegistrationSubmission } from '@/types/course-registration';
|
||||||
|
import { RegistrationStatusLabels } from '@/types/course-registration';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
submission: CourseRegistrationSubmission;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseRegistrationShow({ submission }: Props) {
|
||||||
|
const student = submission.student;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Detail Registrasi KRS" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Detail Registrasi KRS"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={courseRegistrationIndex.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>
|
||||||
|
{student?.user?.profile?.full_name ?? 'N/A'}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{student?.student_number} ·{' '}
|
||||||
|
{student?.department?.name ?? '-'} ·
|
||||||
|
Semester {student?.current_semester ?? '-'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Periode:{' '}
|
||||||
|
{submission.academic_term
|
||||||
|
? formatAcademicTermLabel(
|
||||||
|
submission.academic_term,
|
||||||
|
)
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
submission.status === 'approved'
|
||||||
|
? 'default'
|
||||||
|
: submission.status === 'rejected'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{RegistrationStatusLabels[submission.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Tanda Tangan
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{submission.signature_url ? (
|
||||||
|
<div className="w-fit rounded-md border bg-white p-2">
|
||||||
|
<img
|
||||||
|
src={submission.signature_url}
|
||||||
|
alt="Tanda tangan"
|
||||||
|
className="h-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tidak ada tanda tangan.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Ditandatangani pada{' '}
|
||||||
|
{format(
|
||||||
|
new Date(submission.signed_at),
|
||||||
|
'd MMM yyyy, HH:mm',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Mata Kuliah (
|
||||||
|
{submission.course_registrations.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
{submission.course_registrations.map((registration) => (
|
||||||
|
<div
|
||||||
|
key={registration.id}
|
||||||
|
className="rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<p className="font-medium">
|
||||||
|
{registration.course_class?.course?.name ??
|
||||||
|
'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{registration.course_class?.course?.code ??
|
||||||
|
'-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Riwayat</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
{submission.logs.map((log, index) => (
|
||||||
|
<LogBubble
|
||||||
|
key={log.id}
|
||||||
|
log={log}
|
||||||
|
isResubmission={
|
||||||
|
log.status === 'submitted' &&
|
||||||
|
submission.logs
|
||||||
|
.slice(0, index)
|
||||||
|
.some((l) => l.status === 'submitted')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogBubble({
|
||||||
|
log,
|
||||||
|
isResubmission,
|
||||||
|
}: {
|
||||||
|
log: CourseRegistrationSubmission['logs'][number];
|
||||||
|
isResubmission: boolean;
|
||||||
|
}) {
|
||||||
|
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
|
||||||
|
|
||||||
|
const message =
|
||||||
|
log.status === 'approved'
|
||||||
|
? `${actorName} menyetujui KRS ini.`
|
||||||
|
: log.status === 'rejected'
|
||||||
|
? `${actorName} menolak KRS ini dengan alasan: "${log.reason}"`
|
||||||
|
: isResubmission
|
||||||
|
? `${actorName} mengajukan ulang KRS.`
|
||||||
|
: `${actorName} mengajukan KRS.`;
|
||||||
|
|
||||||
|
const colorClasses =
|
||||||
|
log.status === 'approved'
|
||||||
|
? 'border-primary/30 bg-primary/10'
|
||||||
|
: log.status === 'rejected'
|
||||||
|
? 'border-destructive/30 bg-destructive/10'
|
||||||
|
: 'border-border bg-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border px-3 py-2 ${colorClasses}`}>
|
||||||
|
<p className="text-sm">{message}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{format(new Date(log.created_at), 'd MMM yyyy, HH:mm')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,7 @@
|
|||||||
|
import { Form, Head, Link } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
import { DatePicker } from '@/components/date-picker';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
@ -25,10 +29,6 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { index, store } from '@/routes/admin/users/students';
|
import { index, store } from '@/routes/admin/users/students';
|
||||||
import { formatDepartmentLabel } from '@/types/department';
|
import { formatDepartmentLabel } from '@/types/department';
|
||||||
import { Form, Head, Link } from '@inertiajs/react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { ArrowLeft, Save } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type Department = { id: number; name: string; degree_level: string | null };
|
type Department = { id: number; name: string; degree_level: string | null };
|
||||||
type Lecturer = {
|
type Lecturer = {
|
||||||
@ -336,7 +336,9 @@ export default function StudentCreate({ departments, lecturers }: Props) {
|
|||||||
placeholder="Contoh: 1"
|
placeholder="Contoh: 1"
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.current_semester}
|
message={
|
||||||
|
errors.current_semester
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -376,7 +378,8 @@ export default function StudentCreate({ departments, lecturers }: Props) {
|
|||||||
key={lect.id}
|
key={lect.id}
|
||||||
value={lect}
|
value={lect}
|
||||||
>
|
>
|
||||||
{lect.user?.profile
|
{lect.user
|
||||||
|
?.profile
|
||||||
?.full_name ??
|
?.full_name ??
|
||||||
'N/A'}{' '}
|
'N/A'}{' '}
|
||||||
-{' '}
|
-{' '}
|
||||||
@ -389,7 +392,9 @@ export default function StudentCreate({ departments, lecturers }: Props) {
|
|||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.academic_advisor_id}
|
message={
|
||||||
|
errors.academic_advisor_id
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
import { Form, Head, Link } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
import { DatePicker } from '@/components/date-picker';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
@ -25,10 +29,6 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { index, update } from '@/routes/admin/users/students';
|
import { index, update } from '@/routes/admin/users/students';
|
||||||
import { formatDepartmentLabel } from '@/types/department';
|
import { formatDepartmentLabel } from '@/types/department';
|
||||||
import { Form, Head, Link } from '@inertiajs/react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { ArrowLeft, Save } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type Department = { id: number; name: string; degree_level: string | null };
|
type Department = { id: number; name: string; degree_level: string | null };
|
||||||
type Lecturer = {
|
type Lecturer = {
|
||||||
@ -362,8 +362,8 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
name="enrollment_year"
|
name="enrollment_year"
|
||||||
type="number"
|
type="number"
|
||||||
defaultValue={
|
defaultValue={
|
||||||
user.student?.enrollment_year ??
|
user.student
|
||||||
''
|
?.enrollment_year ?? ''
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
@ -389,7 +389,9 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.current_semester}
|
message={
|
||||||
|
errors.current_semester
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -429,7 +431,8 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
key={lect.id}
|
key={lect.id}
|
||||||
value={lect}
|
value={lect}
|
||||||
>
|
>
|
||||||
{lect.user?.profile
|
{lect.user
|
||||||
|
?.profile
|
||||||
?.full_name ??
|
?.full_name ??
|
||||||
'N/A'}{' '}
|
'N/A'}{' '}
|
||||||
-{' '}
|
-{' '}
|
||||||
@ -442,7 +445,9 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
|
|||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.academic_advisor_id}
|
message={
|
||||||
|
errors.academic_advisor_id
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
90
resources/js/pages/student/course-registrations/columns.tsx
Normal file
90
resources/js/pages/student/course-registrations/columns.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { show } from '@/routes/student/course-registrations';
|
||||||
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
|
import type { RegistrationStatus } from '@/types/course-registration';
|
||||||
|
import { RegistrationStatusLabels } from '@/types/course-registration';
|
||||||
|
|
||||||
|
export type SubmissionSummary = {
|
||||||
|
id: number;
|
||||||
|
status: RegistrationStatus;
|
||||||
|
signed_at: string;
|
||||||
|
academic_term: { id: number; name: string; semester: string } | null;
|
||||||
|
course_registrations_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submissionColumns: ColumnDef<SubmissionSummary>[] = [
|
||||||
|
{
|
||||||
|
id: 'academic_term',
|
||||||
|
header: () => <span>Periode Akademik</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const term = row.original.academic_term;
|
||||||
|
|
||||||
|
return term ? formatAcademicTermLabel(term) : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'count',
|
||||||
|
header: () => <span className="block text-center">Jumlah</span>,
|
||||||
|
meta: {
|
||||||
|
className: 'w-[100px] text-center',
|
||||||
|
headerClassName: 'w-[100px] text-center',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => row.original.course_registrations_count,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: () => <span className="block text-center">Status</span>,
|
||||||
|
meta: {
|
||||||
|
className: 'w-[140px] text-center',
|
||||||
|
headerClassName: 'w-[140px] text-center',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
status === 'approved'
|
||||||
|
? 'default'
|
||||||
|
: status === 'rejected'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{RegistrationStatusLabels[status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signed_at',
|
||||||
|
header: () => <span>Tanggal</span>,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
format(new Date(row.original.signed_at), 'd MMM yyyy, HH:mm'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'Lihat Detail',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
href: show.url(row.original.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
259
resources/js/pages/student/course-registrations/create.tsx
Normal file
259
resources/js/pages/student/course-registrations/create.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import { Form, Head, Link } from '@inertiajs/react';
|
||||||
|
import { ArrowLeft, XCircle } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { SignaturePad } from '@/components/signature-pad';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { index, store } from '@/routes/student/course-registrations';
|
||||||
|
import type { AcademicTerm } from '@/types/academic-term';
|
||||||
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
|
|
||||||
|
type Lecturer = {
|
||||||
|
user: { profile: { full_name: string } | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Course = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
credits: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CourseClass = {
|
||||||
|
id: number;
|
||||||
|
lecturer: Lecturer | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Student = {
|
||||||
|
student_number: string;
|
||||||
|
current_semester: number;
|
||||||
|
department: { name: string } | null;
|
||||||
|
user: { profile: { full_name: string } | null } | null;
|
||||||
|
academic_advisor: {
|
||||||
|
user: { profile: { full_name: string } | null } | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AvailableCourse = {
|
||||||
|
course: Course;
|
||||||
|
course_class: CourseClass | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Registration = {
|
||||||
|
id: number;
|
||||||
|
course_class: { id: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Submission = {
|
||||||
|
status: 'submitted' | 'approved' | 'rejected';
|
||||||
|
rejection_reason: string | null;
|
||||||
|
course_registrations: Registration[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
student: Student;
|
||||||
|
academicTerm: AcademicTerm;
|
||||||
|
submission: Submission | null;
|
||||||
|
availableCourses: AvailableCourse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseRegistrationCreate({
|
||||||
|
student,
|
||||||
|
academicTerm,
|
||||||
|
submission,
|
||||||
|
availableCourses,
|
||||||
|
}: Props) {
|
||||||
|
const [selected, setSelected] = useState<number[]>(() => {
|
||||||
|
if (submission && submission.course_registrations.length > 0) {
|
||||||
|
return submission.course_registrations.map(
|
||||||
|
(registration) => registration.course_class.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableCourses
|
||||||
|
.filter((item) => item.course_class)
|
||||||
|
.map((item) => item.course_class!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle(courseClassId: number, checked: boolean) {
|
||||||
|
setSelected((prev) =>
|
||||||
|
checked
|
||||||
|
? [...prev, courseClassId]
|
||||||
|
: prev.filter((id) => id !== courseClassId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCredits = availableCourses
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.course_class && selected.includes(item.course_class.id),
|
||||||
|
)
|
||||||
|
.reduce((sum, item) => sum + item.course.credits, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Isi KRS" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Isi Kartu Rencana Studi"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={index.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{student.user?.profile?.full_name ?? '-'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{student.student_number} ·{' '}
|
||||||
|
{student.department?.name ?? '-'} · Semester{' '}
|
||||||
|
{student.current_semester}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Periode Akademik:{' '}
|
||||||
|
{formatAcademicTermLabel(academicTerm)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Dosen Pembimbing Akademik:{' '}
|
||||||
|
{student.academic_advisor?.user?.profile
|
||||||
|
?.full_name ?? '-'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{submission?.status === 'rejected' && (
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardContent className="pt-6 text-sm">
|
||||||
|
<p className="flex items-center gap-2 font-medium text-destructive">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
KRS Anda ditolak
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{submission.rejection_reason ??
|
||||||
|
'Tidak ada alasan yang diberikan.'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Silakan perbaiki pilihan mata kuliah Anda dan
|
||||||
|
ajukan kembali di bawah ini.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form action={store()}>
|
||||||
|
{({ errors, processing }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Pilih Mata Kuliah Semester{' '}
|
||||||
|
{student.current_semester}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Total {totalCredits} SKS dipilih
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3">
|
||||||
|
{availableCourses.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Belum ada mata kuliah/kelas untuk
|
||||||
|
semester ini pada periode aktif.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{availableCourses.map(
|
||||||
|
({ course, course_class }) => (
|
||||||
|
<label
|
||||||
|
key={course.id}
|
||||||
|
className="flex items-start gap-3 rounded-md border p-3 has-disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={
|
||||||
|
course_class
|
||||||
|
? selected.includes(
|
||||||
|
course_class.id,
|
||||||
|
)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
disabled={!course_class}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
course_class &&
|
||||||
|
toggle(
|
||||||
|
course_class.id,
|
||||||
|
checked === true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{course.code} -{' '}
|
||||||
|
{course.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{course.credits} SKS
|
||||||
|
·{' '}
|
||||||
|
{course_class
|
||||||
|
? (course_class.lecturer
|
||||||
|
?.user?.profile
|
||||||
|
?.full_name ??
|
||||||
|
'Dosen belum ditentukan')
|
||||||
|
: 'Kelas belum dibuka'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{selected.map((courseClassId) => (
|
||||||
|
<input
|
||||||
|
key={courseClassId}
|
||||||
|
type="hidden"
|
||||||
|
name="course_class_ids[]"
|
||||||
|
value={courseClassId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<InputError message={errors.course_class_ids} />
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="border-t pt-6">
|
||||||
|
<SignaturePad
|
||||||
|
name="signature"
|
||||||
|
error={errors.signature}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
processing || selected.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submission?.status === 'rejected'
|
||||||
|
? 'Ajukan Ulang KRS'
|
||||||
|
: 'Tanda Tangan KRS'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
resources/js/pages/student/course-registrations/index.tsx
Normal file
133
resources/js/pages/student/course-registrations/index.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { Head, Link } from '@inertiajs/react';
|
||||||
|
import { XCircle } from 'lucide-react';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { formatRupiah } from '@/lib/currency';
|
||||||
|
import { create } from '@/routes/student/course-registrations';
|
||||||
|
import type { AcademicTerm } from '@/types/academic-term';
|
||||||
|
import type { RegistrationStatus } from '@/types/course-registration';
|
||||||
|
import type { SubmissionSummary } from './columns';
|
||||||
|
import { submissionColumns } from './columns';
|
||||||
|
|
||||||
|
type PaymentStatus = {
|
||||||
|
has_invoice: boolean;
|
||||||
|
is_paid: boolean;
|
||||||
|
amount_due: number | null;
|
||||||
|
paid_total: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveSubmission = {
|
||||||
|
status: RegistrationStatus;
|
||||||
|
rejection_reason: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
academicTerm: AcademicTerm | null;
|
||||||
|
payment: PaymentStatus | null;
|
||||||
|
canSubmit: boolean;
|
||||||
|
activeSubmission: ActiveSubmission | null;
|
||||||
|
submissions: SubmissionSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseRegistrationIndex({
|
||||||
|
academicTerm,
|
||||||
|
payment,
|
||||||
|
canSubmit,
|
||||||
|
activeSubmission,
|
||||||
|
submissions,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="KRS" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Kartu Rencana Studi"
|
||||||
|
description={
|
||||||
|
academicTerm && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Periode aktif: {academicTerm.name} (
|
||||||
|
{academicTerm.semester === 'odd'
|
||||||
|
? 'Ganjil'
|
||||||
|
: 'Genap'}
|
||||||
|
)
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!academicTerm && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||||
|
Belum ada periode akademik yang aktif. Silakan
|
||||||
|
hubungi admin.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{academicTerm && payment && !payment.is_paid && (
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardContent className="pt-6 text-sm">
|
||||||
|
<p className="font-medium text-destructive">
|
||||||
|
Anda belum bisa mengisi KRS periode ini.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{payment.has_invoice
|
||||||
|
? `Tagihan periode ini belum lunas (terbayar ${formatRupiah(payment.paid_total ?? 0)} dari ${formatRupiah(payment.amount_due ?? 0)}).`
|
||||||
|
: 'Tagihan untuk periode ini belum diterbitkan. Silakan hubungi staf keuangan.'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{academicTerm && payment?.is_paid && canSubmit && (
|
||||||
|
<Card
|
||||||
|
className={
|
||||||
|
activeSubmission?.status === 'rejected'
|
||||||
|
? 'border-destructive/50'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-col gap-3 pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
{activeSubmission?.status === 'rejected' ? (
|
||||||
|
<>
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
KRS Anda untuk periode ini ditolak
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{activeSubmission.rejection_reason ??
|
||||||
|
'Tidak ada alasan yang diberikan.'}{' '}
|
||||||
|
Silakan perbaiki dan ajukan kembali.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Anda belum mengisi KRS untuk periode
|
||||||
|
aktif ini.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={create.url()}>
|
||||||
|
{activeSubmission?.status === 'rejected'
|
||||||
|
? 'Ajukan Ulang KRS'
|
||||||
|
: 'Isi KRS'}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={submissionColumns}
|
||||||
|
data={submissions}
|
||||||
|
emptyText="Anda belum pernah mengajukan KRS."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
resources/js/pages/student/course-registrations/show.tsx
Normal file
180
resources/js/pages/student/course-registrations/show.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { Head, Link } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { index } from '@/routes/student/course-registrations';
|
||||||
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
|
import type { CourseRegistrationSubmission } from '@/types/course-registration';
|
||||||
|
import { RegistrationStatusLabels } from '@/types/course-registration';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
submission: CourseRegistrationSubmission;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseRegistrationShow({ submission }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Detail KRS" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Detail Kartu Rencana Studi"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={index.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>
|
||||||
|
{submission.academic_term
|
||||||
|
? formatAcademicTermLabel(
|
||||||
|
submission.academic_term,
|
||||||
|
)
|
||||||
|
: '-'}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Ditandatangani pada{' '}
|
||||||
|
{format(
|
||||||
|
new Date(submission.signed_at),
|
||||||
|
'd MMM yyyy, HH:mm',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
submission.status === 'approved'
|
||||||
|
? 'default'
|
||||||
|
: submission.status === 'rejected'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{RegistrationStatusLabels[submission.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Tanda Tangan
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{submission.signature_url ? (
|
||||||
|
<div className="w-fit rounded-md border bg-white p-2">
|
||||||
|
<img
|
||||||
|
src={submission.signature_url}
|
||||||
|
alt="Tanda tangan"
|
||||||
|
className="h-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tidak ada tanda tangan.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Mata Kuliah (
|
||||||
|
{submission.course_registrations.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
{submission.course_registrations.map((registration) => (
|
||||||
|
<div
|
||||||
|
key={registration.id}
|
||||||
|
className="rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<p className="font-medium">
|
||||||
|
{registration.course_class?.course?.code ??
|
||||||
|
'-'}{' '}
|
||||||
|
-{' '}
|
||||||
|
{registration.course_class?.course?.name ??
|
||||||
|
'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{registration.course_class?.course
|
||||||
|
?.credits ?? '-'}{' '}
|
||||||
|
SKS ·{' '}
|
||||||
|
{registration.course_class?.lecturer?.user
|
||||||
|
?.profile?.full_name ??
|
||||||
|
'Dosen belum ditentukan'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Riwayat</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
{submission.logs.map((log, index) => (
|
||||||
|
<LogBubble
|
||||||
|
key={log.id}
|
||||||
|
log={log}
|
||||||
|
isResubmission={
|
||||||
|
log.status === 'submitted' &&
|
||||||
|
submission.logs
|
||||||
|
.slice(0, index)
|
||||||
|
.some((l) => l.status === 'submitted')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogBubble({
|
||||||
|
log,
|
||||||
|
isResubmission,
|
||||||
|
}: {
|
||||||
|
log: CourseRegistrationSubmission['logs'][number];
|
||||||
|
isResubmission: boolean;
|
||||||
|
}) {
|
||||||
|
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
|
||||||
|
|
||||||
|
const message =
|
||||||
|
log.status === 'approved'
|
||||||
|
? `${actorName} menyetujui KRS ini.`
|
||||||
|
: log.status === 'rejected'
|
||||||
|
? `${actorName} menolak KRS ini dengan alasan: "${log.reason}"`
|
||||||
|
: isResubmission
|
||||||
|
? `${actorName} mengajukan ulang KRS.`
|
||||||
|
: `${actorName} mengajukan KRS.`;
|
||||||
|
|
||||||
|
const colorClasses =
|
||||||
|
log.status === 'approved'
|
||||||
|
? 'border-primary/30 bg-primary/10'
|
||||||
|
: log.status === 'rejected'
|
||||||
|
? 'border-destructive/30 bg-destructive/10'
|
||||||
|
: 'border-border bg-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border px-3 py-2 ${colorClasses}`}>
|
||||||
|
<p className="text-sm">{message}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{format(new Date(log.created_at), 'd MMM yyyy, HH:mm')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ export type User = {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
email_verified_at: string | null;
|
email_verified_at: string | null;
|
||||||
two_factor_enabled?: boolean;
|
two_factor_enabled?: boolean;
|
||||||
|
roles?: { id: number; name: string }[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@ -15,31 +15,63 @@ export const RegistrationStatusLabels: Record<RegistrationStatus, string> = {
|
|||||||
export type CourseRegistrationStudent = {
|
export type CourseRegistrationStudent = {
|
||||||
id: number;
|
id: number;
|
||||||
student_number: string;
|
student_number: string;
|
||||||
|
current_semester: number;
|
||||||
department: { id: number; name: string } | null;
|
department: { id: number; name: string } | null;
|
||||||
user: { profile: { full_name: string } | null } | null;
|
user: { profile: { full_name: string } | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CourseRegistrationCourseClass = {
|
export type CourseRegistrationCourseClass = {
|
||||||
id: number;
|
id: number;
|
||||||
course: { id: number; code: string; name: string } | null;
|
academic_term_id: number;
|
||||||
};
|
course: {
|
||||||
|
|
||||||
export type CourseRegistrationApprover = {
|
|
||||||
id: number;
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
semester_number: number | null;
|
||||||
|
department_id: number;
|
||||||
|
credits?: number;
|
||||||
|
} | null;
|
||||||
|
lecturer?: {
|
||||||
user: { profile: { full_name: string } | null } | null;
|
user: { profile: { full_name: string } | null } | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CourseRegistration = {
|
export type CourseRegistration = {
|
||||||
id: number;
|
id: number;
|
||||||
student_id: number;
|
student_id: number;
|
||||||
student: CourseRegistrationStudent | null;
|
|
||||||
academic_term_id: number;
|
academic_term_id: number;
|
||||||
academic_term: { id: number; name: string; semester: string } | null;
|
|
||||||
course_class_id: number;
|
course_class_id: number;
|
||||||
course_class: CourseRegistrationCourseClass | null;
|
course_class: CourseRegistrationCourseClass | null;
|
||||||
status: RegistrationStatus | null;
|
|
||||||
approved_by: number | null;
|
|
||||||
approver: CourseRegistrationApprover | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CourseRegistrationActor = {
|
||||||
|
id: number;
|
||||||
|
profile: { full_name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseRegistrationSubmissionLog = {
|
||||||
|
id: number;
|
||||||
|
status: RegistrationStatus;
|
||||||
|
reason: string | null;
|
||||||
|
actor: CourseRegistrationActor | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseRegistrationSubmission = {
|
||||||
|
id: number;
|
||||||
|
student_id: number;
|
||||||
|
student: CourseRegistrationStudent | null;
|
||||||
|
academic_term_id: number;
|
||||||
|
academic_term: { id: number; name: string; semester: string } | null;
|
||||||
|
status: RegistrationStatus;
|
||||||
|
signed_at: string;
|
||||||
|
signature_url: string | null;
|
||||||
|
rejection_reason: string | null;
|
||||||
|
reviewed_by: number | null;
|
||||||
|
reviewer: CourseRegistrationActor | null;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
course_registrations: CourseRegistration[];
|
||||||
|
logs: CourseRegistrationSubmissionLog[];
|
||||||
|
};
|
||||||
|
|||||||
@ -61,7 +61,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
||||||
Route::resource('course-registrations', CourseRegistrationController::class)->except(['create', 'edit', 'show']);
|
Route::resource('course-registrations', CourseRegistrationController::class)->only(['index', 'store']);
|
||||||
|
|
||||||
|
Route::prefix('course-registrations/{submission}')->name('course-registrations.')->group(function () {
|
||||||
|
Route::get('/', [CourseRegistrationController::class, 'show'])->name('show');
|
||||||
|
Route::patch('approve', [CourseRegistrationController::class, 'approve'])->name('approve');
|
||||||
|
Route::patch('reject', [CourseRegistrationController::class, 'reject'])->name('reject');
|
||||||
|
});
|
||||||
|
|
||||||
Route::resource('course-classes', CourseClassController::class)->except(['create', 'edit', 'show']);
|
Route::resource('course-classes', CourseClassController::class)->except(['create', 'edit', 'show']);
|
||||||
|
|
||||||
@ -72,6 +78,13 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::middleware('role:mahasiswa')->prefix('course-registrations')->name('student.course-registrations.')->group(function () {
|
||||||
|
Route::get('/', [CourseRegistrationController::class, 'mine'])->name('index');
|
||||||
|
Route::get('create', [CourseRegistrationController::class, 'create'])->name('create');
|
||||||
|
Route::post('/', [CourseRegistrationController::class, 'store'])->name('store');
|
||||||
|
Route::get('{submission}', [CourseRegistrationController::class, 'show'])->name('show');
|
||||||
|
});
|
||||||
|
|
||||||
Route::prefix('admin/announcements')->name('admin.announcements.')->group(function () {
|
Route::prefix('admin/announcements')->name('admin.announcements.')->group(function () {
|
||||||
Route::get('/', [AnnouncementController::class, 'index'])->name('index');
|
Route::get('/', [AnnouncementController::class, 'index'])->name('index');
|
||||||
Route::post('/', [AnnouncementController::class, 'store'])->name('store');
|
Route::post('/', [AnnouncementController::class, 'store'])->name('store');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user