From e3633caea53c9a8e94c338ff4b90f9a3664797d8 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 25 Aug 2026 01:14:10 +0700 Subject: [PATCH] feat: implement letter request management with CRUD operations --- app/Enums/LetterStatus.php | 21 ++ .../Admin/Manage/LetterRequestController.php | 54 +++ .../Admin/Manage/LetterRequestRequest.php | 26 ++ app/Models/LetterRequest.php | 58 +++ app/Models/Student.php | 5 + .../Admin/Manage/LetterRequestService.php | 70 ++++ ...25_000003_create_letter_requests_table.php | 29 ++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/LetterRequestSeeder.php | 37 ++ resources/js/components/app-sidebar.tsx | 12 + .../admin/manage/letter-requests/columns.tsx | 160 +++++++++ .../admin/manage/letter-requests/index.tsx | 329 ++++++++++++++++++ resources/js/types/letter-request.ts | 44 +++ routes/admin.php | 3 + 14 files changed, 849 insertions(+) create mode 100644 app/Enums/LetterStatus.php create mode 100644 app/Http/Controllers/Admin/Manage/LetterRequestController.php create mode 100644 app/Http/Requests/Admin/Manage/LetterRequestRequest.php create mode 100644 app/Models/LetterRequest.php create mode 100644 app/Services/Admin/Manage/LetterRequestService.php create mode 100644 database/migrations/2026_08_25_000003_create_letter_requests_table.php create mode 100644 database/seeders/LetterRequestSeeder.php create mode 100644 resources/js/pages/admin/manage/letter-requests/columns.tsx create mode 100644 resources/js/pages/admin/manage/letter-requests/index.tsx create mode 100644 resources/js/types/letter-request.ts diff --git a/app/Enums/LetterStatus.php b/app/Enums/LetterStatus.php new file mode 100644 index 0000000..6f34615 --- /dev/null +++ b/app/Enums/LetterStatus.php @@ -0,0 +1,21 @@ + 'Diajukan', + self::InProcess => 'Diproses', + self::Completed => 'Selesai', + self::Rejected => 'Ditolak', + }; + } +} diff --git a/app/Http/Controllers/Admin/Manage/LetterRequestController.php b/app/Http/Controllers/Admin/Manage/LetterRequestController.php new file mode 100644 index 0000000..65a37f0 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/LetterRequestController.php @@ -0,0 +1,54 @@ + $this->service->paginated(...$request->validatedWithDefaults()), + 'students' => $this->studentService->getAllForSelect(), + ]); + } + + public function store(LetterRequestRequest $request): RedirectResponse + { + $this->service->create($request->validated(), $request->file('result')); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil ditambahkan.']); + + return to_route('admin.manage.letter-requests.index'); + } + + public function update(LetterRequestRequest $request, LetterRequest $letterRequest): RedirectResponse + { + $this->service->update($letterRequest, $request->validated(), $request->file('result')); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil diperbarui.']); + + return to_route('admin.manage.letter-requests.index'); + } + + public function destroy(LetterRequest $letterRequest): RedirectResponse + { + $this->service->delete($letterRequest); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil dihapus.'])->back(); + } +} diff --git a/app/Http/Requests/Admin/Manage/LetterRequestRequest.php b/app/Http/Requests/Admin/Manage/LetterRequestRequest.php new file mode 100644 index 0000000..7879d19 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/LetterRequestRequest.php @@ -0,0 +1,26 @@ + ['required', 'integer', Rule::exists('students', 'id')], + 'letter_type' => ['required', 'string', 'max:100'], + 'purpose' => ['nullable', 'string'], + 'status' => ['nullable', Rule::enum(LetterStatus::class)], + 'result' => ['nullable', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png,doc,docx'], + ]; + } +} diff --git a/app/Models/LetterRequest.php b/app/Models/LetterRequest.php new file mode 100644 index 0000000..5f4edab --- /dev/null +++ b/app/Models/LetterRequest.php @@ -0,0 +1,58 @@ + LetterStatus::class, + 'submitted_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('letter_result')->singleFile(); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function processor(): BelongsTo + { + return $this->belongsTo(User::class, 'processed_by'); + } + + protected function resultUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMediaUrl('letter_result') ?: null, + ); + } + + protected function resultName(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMedia('letter_result')?->file_name, + ); + } +} diff --git a/app/Models/Student.php b/app/Models/Student.php index 7b42ace..10b6779 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -61,4 +61,9 @@ public function courseRegistrations(): HasMany { return $this->hasMany(CourseRegistration::class); } + + public function letterRequests(): HasMany + { + return $this->hasMany(LetterRequest::class); + } } diff --git a/app/Services/Admin/Manage/LetterRequestService.php b/app/Services/Admin/Manage/LetterRequestService.php new file mode 100644 index 0000000..8dbacc2 --- /dev/null +++ b/app/Services/Admin/Manage/LetterRequestService.php @@ -0,0 +1,70 @@ +select(['id', 'student_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at']) + ->with(['student.user.profile', 'student.department', 'processor.profile']) + ->when($search, fn ($q) => $q->where('letter_type', 'like', "%{$search}%") + ->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%") + ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))) + ->orderBy($sort, $direction) + ->paginate($perPage); + } + + public function create(array $data, ?UploadedFile $result): LetterRequest + { + $status = $data['status'] ?? LetterStatus::Submitted->value; + + $letterRequest = LetterRequest::create([ + 'student_id' => $data['student_id'], + 'letter_type' => $data['letter_type'], + 'purpose' => $data['purpose'] ?? null, + 'status' => $status, + 'processed_by' => $status !== LetterStatus::Submitted->value ? auth()->id() : null, + 'submitted_at' => now(), + 'completed_at' => $status === LetterStatus::Completed->value ? now() : null, + ]); + + if ($result) { + $letterRequest->addMedia($result)->toMediaCollection('letter_result'); + } + + return $letterRequest; + } + + public function update(LetterRequest $letterRequest, array $data, ?UploadedFile $result): LetterRequest + { + $status = $data['status'] ?? LetterStatus::Submitted->value; + + $letterRequest->student_id = $data['student_id']; + $letterRequest->letter_type = $data['letter_type']; + $letterRequest->purpose = $data['purpose'] ?? null; + $letterRequest->status = $status; + $letterRequest->processed_by = $status !== LetterStatus::Submitted->value ? auth()->id() : null; + $letterRequest->completed_at = $status === LetterStatus::Completed->value + ? ($letterRequest->completed_at ?? now()) + : null; + $letterRequest->update(); + + if ($result) { + $letterRequest->addMedia($result)->toMediaCollection('letter_result'); + } + + return $letterRequest; + } + + public function delete(LetterRequest $letterRequest): bool + { + return $letterRequest->delete(); + } +} diff --git a/database/migrations/2026_08_25_000003_create_letter_requests_table.php b/database/migrations/2026_08_25_000003_create_letter_requests_table.php new file mode 100644 index 0000000..714cc15 --- /dev/null +++ b/database/migrations/2026_08_25_000003_create_letter_requests_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->string('letter_type', 100); + $table->text('purpose')->nullable(); + $table->enum('status', array_values(LetterStatus::cases()))->nullable()->default(LetterStatus::Submitted->value); + $table->foreignId('processed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('submitted_at')->nullable()->useCurrent(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('letter_requests'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c1836d7..e15ef5b 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,7 @@ public function run(): void TuitionInvoiceSeeder::class, TuitionPaymentSeeder::class, AnnouncementSeeder::class, + LetterRequestSeeder::class, ]); } } diff --git a/database/seeders/LetterRequestSeeder.php b/database/seeders/LetterRequestSeeder.php new file mode 100644 index 0000000..2b3c2fd --- /dev/null +++ b/database/seeders/LetterRequestSeeder.php @@ -0,0 +1,37 @@ + 1, + 'letter_type' => 'Surat Aktif Kuliah', + 'purpose' => 'Persyaratan pengajuan beasiswa', + 'status' => 'completed', + 'processed_by' => 5, + 'submitted_at' => '2026-07-01 09:00:00', + 'completed_at' => '2026-07-02 14:00:00', + 'created_at' => '2026-07-01 09:00:00', + 'updated_at' => '2026-07-02 14:00:00', + ], + [ + 'student_id' => 2, + 'letter_type' => 'Surat Keterangan Lulus Sementara', + 'purpose' => 'Persyaratan melamar magang', + 'status' => 'in_process', + 'processed_by' => 5, + 'submitted_at' => '2026-08-01 09:00:00', + 'completed_at' => null, + 'created_at' => '2026-08-01 09:00:00', + 'updated_at' => '2026-08-01 09:00:00', + ], + ]); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index eb7236f..e856406 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -16,6 +16,7 @@ import { FileCheck2, FileText, GraduationCap, + Mail, Megaphone, Receipt, School, @@ -42,6 +43,7 @@ import { index as attendancesRoute } from '@/routes/admin/manage/attendances'; import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes'; import { index as courseRegistrationsRoute } from '@/routes/admin/manage/course-registrations'; import { index as coursesRoute } from '@/routes/admin/manage/courses'; +import { index as letterRequestsRoute } from '@/routes/admin/manage/letter-requests'; import { index as materialsRoute } from '@/routes/admin/manage/materials'; import { index as schedulesRoute } from '@/routes/admin/manage/schedules'; import { index as tuitionInvoicesRoute } from '@/routes/admin/manage/tuition-invoices'; @@ -141,6 +143,16 @@ const data: { }, ], }, + { + label: 'Layanan', + items: [ + { + name: 'Surat Permohonan', + url: letterRequestsRoute.url(), + icon: Mail, + }, + ], + }, { label: 'Pengguna', items: [ diff --git a/resources/js/pages/admin/manage/letter-requests/columns.tsx b/resources/js/pages/admin/manage/letter-requests/columns.tsx new file mode 100644 index 0000000..9b63224 --- /dev/null +++ b/resources/js/pages/admin/manage/letter-requests/columns.tsx @@ -0,0 +1,160 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; +import { Paperclip, Pencil, Trash2 } from 'lucide-react'; +import { RowActions } from '@/components/row-actions'; +import { Badge } from '@/components/ui/badge'; +import type { LetterRequest, LetterStatus } from '@/types/letter-request'; +import { LetterStatusLabels } from '@/types/letter-request'; + +export type { LetterRequest } from '@/types/letter-request'; + +type CreateColumnsParams = { + handleEdit: (letterRequest: LetterRequest) => void; + handleDeleteClick: (letterRequest: LetterRequest) => void; +}; + +export function createLetterRequestColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + accessorKey: 'student.student_number', + header: () => Mahasiswa, + cell: ({ row }) => { + const student = row.original.student; + + return ( +
+

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

+

+ {student?.student_number} ·{' '} + {student?.department?.name ?? '-'} +

+
+ ); + }, + }, + { + accessorKey: 'letter_type', + header: () => Jenis Surat, + cell: ({ row }) => { + const letterRequest = row.original; + + return ( +
+

+ {letterRequest.letter_type} +

+ {letterRequest.purpose && ( +

+ {letterRequest.purpose} +

+ )} +
+ ); + }, + }, + { + accessorKey: 'status', + header: () => Status, + meta: { + className: 'w-[120px] text-center', + headerClassName: 'w-[120px] text-center', + }, + cell: ({ row }) => { + const status = row.getValue('status') as LetterStatus | null; + + const variant = + status === 'completed' + ? 'default' + : status === 'rejected' + ? 'destructive' + : status === 'in_process' + ? 'secondary' + : 'outline'; + + return ( +
+ {status ? ( + + {LetterStatusLabels[status]} + + ) : ( + - + )} +
+ ); + }, + }, + { + accessorKey: 'processor.profile.full_name', + header: () => Diproses Oleh, + cell: ({ row }) => + row.original.processor?.profile?.full_name ?? '-', + }, + { + accessorKey: 'submitted_at', + header: () => Diajukan, + cell: ({ row }) => { + const submittedAt = row.original.submitted_at; + + return submittedAt + ? format(new Date(submittedAt), 'd MMM yyyy, HH:mm') + : '-'; + }, + }, + { + accessorKey: 'result_name', + header: () => Dokumen, + cell: ({ row }) => { + const letterRequest = row.original; + + if (!letterRequest.result_url) { + return -; + } + + return ( + + + {letterRequest.result_name} + + ); + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => ( + , + onClick: () => handleEdit(row.original), + }, + { + label: 'Hapus', + icon: ( + + ), + onClick: () => handleDeleteClick(row.original), + }, + ]} + /> + ), + }, + ]; +} diff --git a/resources/js/pages/admin/manage/letter-requests/index.tsx b/resources/js/pages/admin/manage/letter-requests/index.tsx new file mode 100644 index 0000000..51eee88 --- /dev/null +++ b/resources/js/pages/admin/manage/letter-requests/index.tsx @@ -0,0 +1,329 @@ +import { Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import type { PaginationState } from '@/components/data-table'; +import { DataTable } from '@/components/data-table'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { FileUploadField } from '@/components/file-upload-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 { useServerTable } from '@/hooks/use-server-table'; +import { + index as letterRequestIndex, + destroy, + store, + update, +} from '@/routes/admin/manage/letter-requests'; +import type { + LetterRequest, + LetterRequestStudent, +} from '@/types/letter-request'; +import { LetterStatuses, LetterStatusLabels } from '@/types/letter-request'; +import { createLetterRequestColumns } from './columns'; + +type Props = { + letterRequests: { + data: LetterRequest[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + students: LetterRequestStudent[]; + highlight?: number; +}; + +function studentLabel(student: LetterRequestStudent): string { + return `${student.user?.profile?.full_name ?? 'N/A'} - ${student.student_number}`; +} + +export default function LetterRequestIndex({ + letterRequests, + students, + highlight, +}: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const pagination: PaginationState = { + current_page: letterRequests.current_page, + last_page: letterRequests.last_page, + per_page: letterRequests.per_page, + total: letterRequests.total, + }; + + const { + search, + handlePageChange, + handlePerPageChange, + handleSearchChange, + } = useServerTable({ + route: () => letterRequestIndex.url(), + pagination, + }); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createLetterRequestColumns({ + handleEdit: (letterRequest) => setEditing(letterRequest), + handleDeleteClick: (letterRequest) => setDeleting(letterRequest), + }); + + return ( + <> + + +
+ + Menampilkan surat permohonan dari notifikasi. +

+ ) + } + actions={ + + + } + /> + + + + { + if (!open) { + setEditing(null); + } + }} + editing={editing} + students={students} + /> + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Surat Permohonan" + description={(letterRequest) => + `Apakah Anda yakin ingin menghapus permohonan "${letterRequest.letter_type}" milik "${letterRequest.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function LetterRequestFields({ + errors, + editing, + students, + resetKey, +}: { + errors: Record; + editing?: LetterRequest; + students: LetterRequestStudent[]; + resetKey?: string; +}) { + return ( + <> +
+ + {!editing && } + + +
+
+ + + +
+
+ +