From 2b7b4e2ab01970fe080d9c468da87c4441511f40 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 2 Sep 2026 22:51:09 +0700 Subject: [PATCH] feat: enhance material management with user-specific filtering and attachment preview --- .../AcademicClasses/MaterialController.php | 3 +- .../Admin/AcademicClasses/MaterialRequest.php | 10 +- .../Admin/AcademicClasses/MaterialService.php | 11 +- .../Admin/Manage/CourseClassService.php | 22 ++- database/seeders/RolePermissionSeeder.php | 6 + .../components/attachment-preview-dialog.tsx | 105 ++++++++++ resources/js/components/filter-dialog.tsx | 153 ++++++++++++--- .../academic-classes/materials/columns.tsx | 18 +- .../academic-classes/materials/index.tsx | 181 +++++++++++++----- 9 files changed, 414 insertions(+), 95 deletions(-) create mode 100644 resources/js/components/attachment-preview-dialog.tsx diff --git a/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php b/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php index c5148b0..3c61a1a 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php @@ -23,10 +23,11 @@ public function index(PaginatedRequest $request): Response { return Inertia::render('admin/academic-classes/materials/index', [ 'materials' => $this->service->paginated( + $request->user(), ...$request->validatedWithDefaults(), courseClassId: $request->validated('course_class_id'), ), - 'courseClasses' => $this->courseClassService->getAllForSelect(), + 'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), 'filters' => $request->only(['course_class_id']), ]); } diff --git a/app/Http/Requests/Admin/AcademicClasses/MaterialRequest.php b/app/Http/Requests/Admin/AcademicClasses/MaterialRequest.php index e01888d..8a0aad7 100644 --- a/app/Http/Requests/Admin/AcademicClasses/MaterialRequest.php +++ b/app/Http/Requests/Admin/AcademicClasses/MaterialRequest.php @@ -14,8 +14,16 @@ public function authorize(): bool public function rules(): array { + $user = $this->user(); + + $courseClassRule = Rule::exists('course_classes', 'id'); + + if ($user->hasRole('dosen')) { + $courseClassRule->where('lecturer_id', $user->lecturer?->id); + } + return [ - 'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')], + 'course_class_id' => ['required', 'integer', $courseClassRule], 'title' => ['required', 'string', 'max:150'], 'description' => ['nullable', 'string'], 'meeting_number' => ['nullable', 'integer', 'min:1'], diff --git a/app/Services/Admin/AcademicClasses/MaterialService.php b/app/Services/Admin/AcademicClasses/MaterialService.php index 42aa64e..4016e01 100644 --- a/app/Services/Admin/AcademicClasses/MaterialService.php +++ b/app/Services/Admin/AcademicClasses/MaterialService.php @@ -2,19 +2,28 @@ namespace App\Services\Admin\AcademicClasses; +use App\Enums\RegistrationStatus; use App\Models\Material; +use App\Models\User; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\UploadedFile; class MaterialService { - public function paginated(int $perPage = 25, string $search = '', ?int $courseClassId = null): LengthAwarePaginator + public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null): LengthAwarePaginator { return Material::query() ->select(['id', 'course_class_id', 'title', 'description', 'meeting_number']) ->with('courseClass.course:id,code,name') ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) ->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId)) + ->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))) + ->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) { + $q->whereHas('registrations', function ($q) use ($user) { + $q->where('student_id', $user->student?->id) + ->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved)); + }); + })) ->latest() ->paginate($perPage); } diff --git a/app/Services/Admin/Manage/CourseClassService.php b/app/Services/Admin/Manage/CourseClassService.php index 5958016..681ae3f 100644 --- a/app/Services/Admin/Manage/CourseClassService.php +++ b/app/Services/Admin/Manage/CourseClassService.php @@ -3,16 +3,32 @@ namespace App\Services\Admin\Manage; use App\Models\CourseClass; +use App\Models\User; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; class CourseClassService { - public function getAllForSelect(): Collection + /** + * When $user is a dosen, only their own assigned classes are returned. + * When $user is a mahasiswa, only classes from their own department are returned. + */ + public function getAllForSelect(?User $user = null): Collection { - return CourseClass::select(['id', 'course_id', 'academic_term_id']) - ->with('course:id,code,name,semester_number,department_id') + return CourseClass::query() + ->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']) + ->join('courses', 'courses.id', '=', 'course_classes.course_id') + ->join('departments', 'departments.id', '=', 'courses.department_id') + ->with([ + 'course:id,code,name,semester_number,department_id', + 'course.department:id,name', + ]) + ->when($user?->hasRole('dosen'), fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id)) + ->when($user?->hasRole('mahasiswa'), fn ($q) => $q->where('courses.department_id', $user->student?->department_id)) + ->orderBy('departments.name') + ->orderBy('courses.semester_number') + ->orderBy('courses.name') ->get(); } diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 57fdfe8..6d9ed40 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -47,6 +47,7 @@ public function run(): void 'update-letter-requests', 'delete-letter-requests', 'view-schedules', + 'view-materials', ...$feedbackSelfService, ], 'dosen' => [ @@ -58,6 +59,11 @@ public function run(): void 'approve-course-registrations', 'reject-course-registrations', 'view-schedules', + 'view-materials', + 'create-materials', + 'update-materials', + 'delete-materials', + ...$feedbackSelfService, ...$feedbackSelfService, ], 'staff-admin' => [ diff --git a/resources/js/components/attachment-preview-dialog.tsx b/resources/js/components/attachment-preview-dialog.tsx new file mode 100644 index 0000000..2a27bd5 --- /dev/null +++ b/resources/js/components/attachment-preview-dialog.tsx @@ -0,0 +1,105 @@ +import { Paperclip } from 'lucide-react'; +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; + +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']; +const VIDEO_EXTENSIONS = ['mp4', 'webm']; + +function fileExtension(fileName: string): string { + return fileName.split('.').pop()?.toLowerCase() ?? ''; +} + +type AttachmentPreviewDialogProps = { + fileUrl: string; + fileName: string; + className?: string; +}; + +export function AttachmentPreviewDialog({ + fileUrl, + fileName, + className, +}: AttachmentPreviewDialogProps) { + const [open, setOpen] = useState(false); + const extension = fileExtension(fileName); + const isImage = IMAGE_EXTENSIONS.includes(extension); + const isVideo = VIDEO_EXTENSIONS.includes(extension); + const isPdf = extension === 'pdf'; + + return ( + <> + + + + + + + {fileName} + + + +
+ {isImage && ( + {fileName} + )} + {isVideo && ( +