From e9acf29c20bf44555870af3b6edc8c1776763e45 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 30 Aug 2026 22:13:26 +0700 Subject: [PATCH] feat: implement academic advising and letter request management for lecturers and students --- .../AcademicAdvisingLogController.php | 39 +++++ .../Services/LetterRequestController.php | 48 ++++++ .../Services/AcademicAdvisingLogRequest.php | 23 +++ .../Admin/Services/LetterRequestRequest.php | 11 ++ .../Services/AcademicAdvisingLogService.php | 14 ++ .../Admin/Services/LetterRequestService.php | 19 +++ resources/js/components/app-sidebar.tsx | 71 +++++--- .../academic-advising-logs/columns.tsx | 49 ++++++ .../lecturer/academic-advising-logs/index.tsx | 156 ++++++++++++++++++ .../pages/student/letter-requests/columns.tsx | 91 ++++++++++ .../pages/student/letter-requests/create.tsx | 79 +++++++++ .../pages/student/letter-requests/index.tsx | 40 +++++ .../js/pages/student/letter-requests/show.tsx | 123 ++++++++++++++ routes/admin.php | 24 ++- 14 files changed, 757 insertions(+), 30 deletions(-) create mode 100644 resources/js/pages/lecturer/academic-advising-logs/columns.tsx create mode 100644 resources/js/pages/lecturer/academic-advising-logs/index.tsx create mode 100644 resources/js/pages/student/letter-requests/columns.tsx create mode 100644 resources/js/pages/student/letter-requests/create.tsx create mode 100644 resources/js/pages/student/letter-requests/index.tsx create mode 100644 resources/js/pages/student/letter-requests/show.tsx diff --git a/app/Http/Controllers/Admin/Services/AcademicAdvisingLogController.php b/app/Http/Controllers/Admin/Services/AcademicAdvisingLogController.php index 3a51511..309db1e 100644 --- a/app/Http/Controllers/Admin/Services/AcademicAdvisingLogController.php +++ b/app/Http/Controllers/Admin/Services/AcademicAdvisingLogController.php @@ -6,10 +6,12 @@ use App\Http\Requests\Admin\Services\AcademicAdvisingLogRequest; use App\Http\Requests\PaginatedRequest; use App\Models\AcademicAdvisingLog; +use App\Models\Lecturer; use App\Services\Admin\Services\AcademicAdvisingLogService; use App\Services\Admin\Users\LecturerService; use App\Services\Admin\Users\StudentService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -34,8 +36,36 @@ public function index(PaginatedRequest $request): Response ]); } + public function mine(Request $request): Response + { + $lecturer = $this->currentLecturer($request); + + return Inertia::render('lecturer/academic-advising-logs/index', [ + 'logs' => $this->service->forLecturer($lecturer), + 'advisees' => $lecturer->advisees() + ->with('user.profile') + ->get(['id', 'user_id', 'student_number']), + ]); + } + public function store(AcademicAdvisingLogRequest $request): RedirectResponse { + if ($request->routeIs('lecturer.*')) { + $lecturer = $this->currentLecturer($request); + + $this->service->create([ + 'student_id' => $request->validated('student_id'), + 'lecturer_id' => $lecturer->id, + 'topic' => $request->validated('topic'), + 'notes' => $request->validated('notes'), + 'session_date' => $request->validated('session_date'), + ]); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil dicatat.']); + + return to_route('lecturer.academic-advising-logs.index'); + } + $this->service->create($request->validated()); Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil ditambahkan.']); @@ -58,4 +88,13 @@ public function destroy(AcademicAdvisingLog $academicAdvisingLog): RedirectRespo return Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil dihapus.'])->back(); } + + private function currentLecturer(Request $request): Lecturer + { + $lecturer = $request->user()->lecturer; + + abort_if(! $lecturer, 403); + + return $lecturer; + } } diff --git a/app/Http/Controllers/Admin/Services/LetterRequestController.php b/app/Http/Controllers/Admin/Services/LetterRequestController.php index 37eafbb..044a878 100644 --- a/app/Http/Controllers/Admin/Services/LetterRequestController.php +++ b/app/Http/Controllers/Admin/Services/LetterRequestController.php @@ -6,9 +6,11 @@ use App\Http\Requests\Admin\Services\LetterRequestRequest; use App\Http\Requests\PaginatedRequest; use App\Models\LetterRequest; +use App\Models\Student; use App\Services\Admin\Services\LetterRequestService; use App\Services\Admin\Users\StudentService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -31,8 +33,45 @@ public function index(PaginatedRequest $request): Response ]); } + public function mine(Request $request): Response + { + return Inertia::render('student/letter-requests/index', [ + 'letterRequests' => $this->service->mine($this->currentStudent($request)), + ]); + } + + public function create(): Response + { + return Inertia::render('student/letter-requests/create'); + } + + public function show(Request $request, LetterRequest $letterRequest): Response + { + $student = $this->currentStudent($request); + + abort_if($letterRequest->student_id !== $student->id, 403); + + return Inertia::render('student/letter-requests/show', [ + 'letterRequest' => $this->service->withDetails($letterRequest), + ]); + } + public function store(LetterRequestRequest $request): RedirectResponse { + if ($request->routeIs('student.*')) { + $student = $this->currentStudent($request); + + $letterRequest = $this->service->create([ + 'student_id' => $student->id, + 'letter_type' => $request->validated('letter_type'), + 'purpose' => $request->validated('purpose'), + ], null); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil diajukan.']); + + return to_route('student.letter-requests.show', $letterRequest); + } + $this->service->create($request->validated(), $request->file('result')); Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil ditambahkan.']); @@ -55,4 +94,13 @@ public function destroy(LetterRequest $letterRequest): RedirectResponse return Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil dihapus.'])->back(); } + + private function currentStudent(Request $request): Student + { + $student = $request->user()->student; + + abort_if(! $student, 403); + + return $student; + } } diff --git a/app/Http/Requests/Admin/Services/AcademicAdvisingLogRequest.php b/app/Http/Requests/Admin/Services/AcademicAdvisingLogRequest.php index 4989a60..6ba6e7a 100644 --- a/app/Http/Requests/Admin/Services/AcademicAdvisingLogRequest.php +++ b/app/Http/Requests/Admin/Services/AcademicAdvisingLogRequest.php @@ -9,11 +9,27 @@ class AcademicAdvisingLogRequest extends FormRequest { public function authorize(): bool { + if ($this->routeIs('lecturer.*')) { + return (bool) $this->user()->lecturer; + } + return true; } public function rules(): array { + if ($this->routeIs('lecturer.*')) { + $lecturer = $this->user()->lecturer; + $adviseeIds = $lecturer ? $lecturer->advisees()->pluck('id') : collect(); + + return [ + 'student_id' => ['required', 'integer', Rule::in($adviseeIds)], + 'topic' => ['nullable', 'string', 'max:150'], + 'notes' => ['nullable', 'string'], + 'session_date' => ['nullable', 'date'], + ]; + } + return [ 'student_id' => ['required', 'integer', Rule::exists('students', 'id')], 'lecturer_id' => ['required', 'integer', Rule::exists('lecturers', 'id')], @@ -22,4 +38,11 @@ public function rules(): array 'session_date' => ['nullable', 'date'], ]; } + + public function messages(): array + { + return [ + 'student_id.in' => 'Mahasiswa yang dipilih bukan mahasiswa bimbingan Anda.', + ]; + } } diff --git a/app/Http/Requests/Admin/Services/LetterRequestRequest.php b/app/Http/Requests/Admin/Services/LetterRequestRequest.php index 65e7a8a..43bb35d 100644 --- a/app/Http/Requests/Admin/Services/LetterRequestRequest.php +++ b/app/Http/Requests/Admin/Services/LetterRequestRequest.php @@ -10,11 +10,22 @@ class LetterRequestRequest extends FormRequest { public function authorize(): bool { + if ($this->routeIs('student.*')) { + return (bool) $this->user()->student; + } + return true; } public function rules(): array { + if ($this->routeIs('student.*')) { + return [ + 'letter_type' => ['required', 'string', 'max:100'], + 'purpose' => ['nullable', 'string'], + ]; + } + return [ 'student_id' => ['required', 'integer', Rule::exists('students', 'id')], 'letter_type' => ['required', 'string', 'max:100'], diff --git a/app/Services/Admin/Services/AcademicAdvisingLogService.php b/app/Services/Admin/Services/AcademicAdvisingLogService.php index c91a7df..02ee307 100644 --- a/app/Services/Admin/Services/AcademicAdvisingLogService.php +++ b/app/Services/Admin/Services/AcademicAdvisingLogService.php @@ -3,10 +3,24 @@ namespace App\Services\Admin\Services; use App\Models\AcademicAdvisingLog; +use App\Models\Lecturer; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Collection; class AcademicAdvisingLogService { + /** + * @return Collection + */ + public function forLecturer(Lecturer $lecturer): Collection + { + return AcademicAdvisingLog::query() + ->where('lecturer_id', $lecturer->id) + ->with('student.user.profile') + ->latest() + ->get(); + } + public function paginated(int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator { return AcademicAdvisingLog::query() diff --git a/app/Services/Admin/Services/LetterRequestService.php b/app/Services/Admin/Services/LetterRequestService.php index 9ed5990..dbea71b 100644 --- a/app/Services/Admin/Services/LetterRequestService.php +++ b/app/Services/Admin/Services/LetterRequestService.php @@ -4,11 +4,30 @@ use App\Enums\LetterStatus; use App\Models\LetterRequest; +use App\Models\Student; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\UploadedFile; class LetterRequestService { + /** + * @return Collection + */ + public function mine(Student $student): Collection + { + return LetterRequest::query() + ->where('student_id', $student->id) + ->with('processor.profile') + ->latest() + ->get(); + } + + public function withDetails(LetterRequest $letterRequest): LetterRequest + { + return $letterRequest->load('processor.profile'); + } + public function paginated(int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator { return LetterRequest::query() diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index c9ff402..4f880e7 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -50,12 +50,21 @@ import { index as letterRequestsRoute } from '@/routes/admin/services/letter-req import { index as administratorsRoute } from '@/routes/admin/users/administrators'; import { index as lecturersRoute } from '@/routes/admin/users/lecturers'; import { index as studentsRoute } from '@/routes/admin/users/students'; +import { index as lecturerAcademicAdvisingLogsRoute } from '@/routes/lecturer/academic-advising-logs'; +import { index as studentCourseRegistrationsRoute } from '@/routes/student/course-registrations'; +import { index as studentLetterRequestsRoute } from '@/routes/student/letter-requests'; +import type { Auth } from '@/types/auth'; -const data: { - navMain: (NavGroup | NavItem)[]; - navSecondary: { title: string; url: string; icon: Icon }[]; -} = { - navMain: [ +const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi']; + +function buildNavMain({ + isMahasiswa, + isDosen, +}: { + isMahasiswa: boolean; + isDosen: boolean; +}): (NavGroup | NavItem)[] { + return [ { name: 'Dasbor', url: '#', @@ -106,7 +115,9 @@ const data: { items: [ { name: 'Registrasi KRS', - url: courseRegistrationsRoute.url(), + url: isMahasiswa + ? studentCourseRegistrationsRoute.url() + : courseRegistrationsRoute.url(), icon: FileCheck2, }, { @@ -161,33 +172,43 @@ const data: { items: [ { name: 'Surat Permohonan', - url: letterRequestsRoute.url(), + url: isMahasiswa + ? studentLetterRequestsRoute.url() + : letterRequestsRoute.url(), icon: Mail, }, { name: 'Bimbingan Akademik', - url: academicAdvisingLogsRoute.url(), + url: isDosen + ? lecturerAcademicAdvisingLogsRoute.url() + : academicAdvisingLogsRoute.url(), icon: MessageCircle, }, ], }, - ], - navSecondary: [ - { - title: 'Kritik dan Saran', - url: feedbackRoute.url(), - icon: IconMessageDots, - }, - { - title: 'Bantuan', - url: '#', - icon: IconHelp, - }, - ], -}; + ]; +} + +const navSecondary: { title: string; url: string; icon: Icon }[] = [ + { + title: 'Kritik dan Saran', + url: feedbackRoute.url(), + icon: IconMessageDots, + }, + { + title: 'Bantuan', + url: '#', + icon: IconHelp, + }, +]; export function AppSidebar({ ...props }: React.ComponentProps) { - const { name } = usePage().props; + const { name, auth } = usePage<{ auth: Auth }>().props; + const roleNames = auth?.user?.roles?.map((role) => role.name) ?? []; + const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role)); + const isMahasiswa = !isStaff && roleNames.includes('mahasiswa'); + const isDosen = !isStaff && roleNames.includes('dosen'); + const navMain = buildNavMain({ isMahasiswa, isDosen }); return ( @@ -209,8 +230,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - - + + ); diff --git a/resources/js/pages/lecturer/academic-advising-logs/columns.tsx b/resources/js/pages/lecturer/academic-advising-logs/columns.tsx new file mode 100644 index 0000000..0fdcc95 --- /dev/null +++ b/resources/js/pages/lecturer/academic-advising-logs/columns.tsx @@ -0,0 +1,49 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; +import type { AcademicAdvisingLog } from '@/types/academic-advising-log'; + +export const advisingLogColumns: ColumnDef[] = [ + { + accessorKey: 'student.student_number', + header: () => Mahasiswa, + cell: ({ row }) => { + const student = row.original.student; + + return ( +
+

+ {student?.user?.profile?.full_name ?? 'N/A'} +

+

+ {student?.student_number} +

+
+ ); + }, + }, + { + accessorKey: 'topic', + header: () => Topik, + cell: ({ row }) => row.original.topic || '-', + }, + { + accessorKey: 'notes', + header: () => Catatan, + cell: ({ row }) => ( +

+ {row.original.notes || '-'} +

+ ), + }, + { + accessorKey: 'session_date', + header: () => Tanggal Sesi, + cell: ({ row }) => { + const sessionDate = row.original.session_date; + + return sessionDate + ? format(new Date(sessionDate), 'd MMM yyyy, HH:mm') + : '-'; + }, + }, +]; diff --git a/resources/js/pages/lecturer/academic-advising-logs/index.tsx b/resources/js/pages/lecturer/academic-advising-logs/index.tsx new file mode 100644 index 0000000..578e872 --- /dev/null +++ b/resources/js/pages/lecturer/academic-advising-logs/index.tsx @@ -0,0 +1,156 @@ +import { Head } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import { DataTable } from '@/components/data-table'; +import { DateTimeField } from '@/components/datetime-field'; +import { FormDialog } from '@/components/form-dialog'; +import InputError from '@/components/input-error'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { store } from '@/routes/lecturer/academic-advising-logs'; +import type { AcademicAdvisingLog } from '@/types/academic-advising-log'; +import { advisingLogColumns } from './columns'; + +type Advisee = { + id: number; + student_number: string; + user: { profile: { full_name: string } | null } | null; +}; + +type Props = { + logs: AcademicAdvisingLog[]; + advisees: Advisee[]; +}; + +function adviseeLabel(advisee: Advisee): string { + return `${advisee.user?.profile?.full_name ?? 'N/A'} - ${advisee.student_number}`; +} + +export default function LecturerAcademicAdvisingLogIndex({ + logs, + advisees, +}: Props) { + const [createOpen, setCreateOpen] = useState(false); + + return ( + <> + + +
+ + + + } + /> + + + + +
+ + ); +} + +function CreateForm({ + open, + onOpenChange, + advisees, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + advisees: Advisee[]; +}) { + return ( + onOpenChange(false)} + > + {({ errors }) => ( +
+
+ + + {advisees.length === 0 && ( +

+ Anda belum memiliki mahasiswa bimbingan. +

+ )} + +
+
+ + + +
+
+ +