From b99876fb4c7a1b8f9b3d829307da8b472575c2c3 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 4 Sep 2026 15:20:16 +0700 Subject: [PATCH] feat: enhance assignment and material management; add academic term filtering and improve pagination handling --- .../Assignment/AssignmentController.php | 16 ++++- .../AcademicClasses/MaterialController.php | 16 ++++- .../Manage/CourseClass/CourseClassService.php | 31 +++++++-- resources/js/components/filter-dialog.tsx | 5 ++ .../academic-classes/assignments/index.tsx | 67 ++++++++++++++++++- .../academic-classes/materials/index.tsx | 64 +++++++++++++++++- 6 files changed, 184 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php b/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php index 25538b3..bac5505 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php @@ -23,15 +23,27 @@ public function __construct( public function index(PaginatedRequest $request): Response { + $academicTermSelected = $request->has('academic_term_id'); + $academicTermId = $academicTermSelected + ? ((int) $request->validated('academic_term_id') ?: null) + : $this->academicTermService->getActive()?->id; + return Inertia::render('admin/academic-classes/assignments/index', [ 'assignments' => Inertia::scroll(fn () => $this->service->paginated( $request->user(), ...$request->validatedWithDefaults(), courseClassId: $request->validated('course_class_id'), - academicTermId: $this->academicTermService->getActive()?->id, + academicTermId: $academicTermId, )), 'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), - 'filters' => $request->only(['course_class_id']), + 'filterCourseClasses' => Inertia::always($this->courseClassService->getAllForSelectByAcademicTerm($request->user(), $academicTermId)), + 'academicTerms' => $this->academicTermService->getAllForSelect(), + 'filters' => Inertia::always([ + 'course_class_id' => $request->validated('course_class_id'), + 'academic_term_id' => $academicTermSelected + ? (string) ($academicTermId ?? 0) + : ($academicTermId ? (string) $academicTermId : null), + ]), ]); } diff --git a/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php b/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php index 9cc4b4d..470d701 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/MaterialController.php @@ -23,15 +23,27 @@ public function __construct( public function index(PaginatedRequest $request): Response { + $academicTermSelected = $request->has('academic_term_id'); + $academicTermId = $academicTermSelected + ? ((int) $request->validated('academic_term_id') ?: null) + : $this->academicTermService->getActive()?->id; + return Inertia::render('admin/academic-classes/materials/index', [ 'materials' => Inertia::scroll(fn () => $this->service->paginated( $request->user(), ...$request->validatedWithDefaults(), courseClassId: $request->validated('course_class_id'), - academicTermId: $this->academicTermService->getActive()?->id, + academicTermId: $academicTermId, )), 'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), - 'filters' => $request->only(['course_class_id']), + 'filterCourseClasses' => Inertia::always($this->courseClassService->getAllForSelectByAcademicTerm($request->user(), $academicTermId)), + 'academicTerms' => $this->academicTermService->getAllForSelect(), + 'filters' => Inertia::always([ + 'course_class_id' => $request->validated('course_class_id'), + 'academic_term_id' => $academicTermSelected + ? (string) ($academicTermId ?? 0) + : ($academicTermId ? (string) $academicTermId : null), + ]), ]); } diff --git a/app/Services/Admin/Manage/CourseClass/CourseClassService.php b/app/Services/Admin/Manage/CourseClass/CourseClassService.php index 8145560..0cceef0 100644 --- a/app/Services/Admin/Manage/CourseClass/CourseClassService.php +++ b/app/Services/Admin/Manage/CourseClass/CourseClassService.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Admin\Master\AcademicTermService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -116,8 +117,29 @@ public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademic public function getAllForSelect(User $user): Collection { $isLecturer = $user->hasRole(UserRole::Dosen->value); - $activeAcademicTermId = $isLecturer ? $this->academicTermService->getActive()?->id : null; + $academicTermId = $isLecturer ? $this->academicTermService->getActive()?->id : null; + return $this->queryForSelect($user, $academicTermId) + ->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']); + } + + /** + * Strictly scoped to a single academic term, e.g. for a filter that + * should only offer classes once a specific term has been chosen. + * Returns an empty collection when no term is given. + */ + public function getAllForSelectByAcademicTerm(User $user, ?int $academicTermId): Collection + { + if (! $academicTermId) { + return new Collection; + } + + return $this->queryForSelect($user, $academicTermId) + ->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']); + } + + private function queryForSelect(User $user, ?int $academicTermId): Builder + { return CourseClass::query() ->join('courses', 'courses.id', '=', 'course_classes.course_id') ->join('departments', 'departments.id', '=', 'courses.department_id') @@ -125,12 +147,11 @@ public function getAllForSelect(User $user): Collection 'course:id,code,name,semester_number,department_id', 'course.department:id,name', ]) - ->when($isLecturer, fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id) - ->where('course_classes.academic_term_id', $activeAcademicTermId)) + ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id)) + ->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId)) ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id)) ->orderBy('departments.name') ->orderBy('courses.semester_number') - ->orderBy('courses.name') - ->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']); + ->orderBy('courses.name'); } } diff --git a/resources/js/components/filter-dialog.tsx b/resources/js/components/filter-dialog.tsx index 626a1ce..47d504b 100644 --- a/resources/js/components/filter-dialog.tsx +++ b/resources/js/components/filter-dialog.tsx @@ -45,6 +45,7 @@ export type FilterField = placeholder?: string; type?: 'select'; options: FilterOption[]; + disabled?: boolean; } | { key: string; @@ -52,6 +53,7 @@ export type FilterField = placeholder?: string; type: 'combobox'; groups: FilterOptionGroup[]; + disabled?: boolean; }; type FilterDialogProps = { @@ -85,10 +87,12 @@ function ComboboxFilterField({ isItemEqualToValue={(a: FilterOption, b: FilterOption) => a.value === b.value } + disabled={field.disabled} > @@ -190,6 +194,7 @@ export function FilterDialog({ onValueChange={(value) => handleChange(field.key, value) } + disabled={field.disabled} > ({ + value: String(term.id), + label: formatAcademicTermLabel(term), + })), + }, { key: 'course_class_id', label: 'Kelas', type: 'combobox' as const, - groups: courseClassFilterGroups(courseClasses), + groups: courseClassFilterGroups(filterCourseClasses), + disabled: filterCourseClasses.length === 0, + placeholder: + filterCourseClasses.length === 0 + ? 'Pilih periode akademik dulu' + : undefined, }, ]; @@ -199,6 +224,42 @@ export default function AssignmentIndex({ resetKeys: ['assignments'], }); + function handleApplyFilters(newFilters: Record) { + // Without an explicit `academic_term_id`, the backend falls back to + // the active term, so clearing this filter needs to be sent + // explicitly (value '0') rather than just dropping the key. + const clearedAcademicTerm = + Boolean(filters.academic_term_id) && !newFilters.academic_term_id; + + const nextFilters: Record = { + ...newFilters, + ...(clearedAcademicTerm ? { academic_term_id: '0' } : {}), + }; + + // The class list depends on the selected term, so a previously + // picked class is no longer relevant once the term changes. + const academicTermChanged = + (nextFilters.academic_term_id ?? '0') !== + (filters.academic_term_id ?? '0'); + + if (academicTermChanged) { + delete nextFilters.course_class_id; + } + + applyFilters(nextFilters); + } + + // '0' explicitly means "all terms" — treated as no active filter from + // FilterDialog's point of view, so its Select shows "Semua" (not blank) + // as selected. + const filterDialogActiveFilters = { + ...filters, + academic_term_id: + filters.academic_term_id === '0' + ? undefined + : filters.academic_term_id, + }; + function handleDelete() { if (!deleting) { return; @@ -233,8 +294,8 @@ export default function AssignmentIndex({ <> diff --git a/resources/js/pages/admin/academic-classes/materials/index.tsx b/resources/js/pages/admin/academic-classes/materials/index.tsx index d8aa235..ff0bb71 100644 --- a/resources/js/pages/admin/academic-classes/materials/index.tsx +++ b/resources/js/pages/admin/academic-classes/materials/index.tsx @@ -36,6 +36,7 @@ import { store, update, } from '@/routes/admin/academic-classes/materials'; +import { formatAcademicTermLabel } from '@/types/academic-term'; import type { Material } from '@/types/material'; import { createMaterialCard } from './card'; import { createMaterialColumns } from './columns'; @@ -53,6 +54,12 @@ type CourseClassOption = { type CourseClassGroup = { value: string; items: CourseClassOption[] }; +type AcademicTermOption = { + id: number; + academic_year: string; + semester: string; +}; + type Props = { materials: { data: Material[]; @@ -62,9 +69,12 @@ type Props = { total: number; }; courseClasses: CourseClassOption[]; + filterCourseClasses: CourseClassOption[]; + academicTerms: AcademicTermOption[]; highlight?: number; filters: { course_class_id?: string; + academic_term_id?: string; }; }; @@ -151,6 +161,8 @@ function courseClassFilterGroups( export default function MaterialIndex({ materials, courseClasses, + filterCourseClasses, + academicTerms, highlight, filters, }: Props) { @@ -164,11 +176,24 @@ export default function MaterialIndex({ const canDelete = hasPermission('delete-materials'); const filterFields = [ + { + key: 'academic_term_id', + label: 'Periode Akademik', + options: academicTerms.map((term) => ({ + value: String(term.id), + label: formatAcademicTermLabel(term), + })), + }, { key: 'course_class_id', label: 'Kelas', type: 'combobox' as const, - groups: courseClassFilterGroups(courseClasses), + groups: courseClassFilterGroups(filterCourseClasses), + disabled: filterCourseClasses.length === 0, + placeholder: + filterCourseClasses.length === 0 + ? 'Pilih periode akademik dulu' + : undefined, }, ]; @@ -186,6 +211,39 @@ export default function MaterialIndex({ resetKeys: ['materials'], }); + function handleApplyFilters(newFilters: Record) { + const clearedAcademicTerm = + Boolean(filters.academic_term_id) && !newFilters.academic_term_id; + + const nextFilters: Record = { + ...newFilters, + ...(clearedAcademicTerm ? { academic_term_id: '0' } : {}), + }; + + // The class list depends on the selected term, so a previously + // picked class is no longer relevant once the term changes. + const academicTermChanged = + (nextFilters.academic_term_id ?? '0') !== + (filters.academic_term_id ?? '0'); + + if (academicTermChanged) { + delete nextFilters.course_class_id; + } + + applyFilters(nextFilters); + } + + // '0' explicitly means "all terms" — treated as no active filter from + // FilterDialog's point of view, so its Select shows "Semua" (not blank) + // as selected. + const filterDialogActiveFilters = { + ...filters, + academic_term_id: + filters.academic_term_id === '0' + ? undefined + : filters.academic_term_id, + }; + function handleDelete() { if (!deleting) { return; @@ -214,8 +272,8 @@ export default function MaterialIndex({ <>