feat: enhance assignment and material management; add academic term filtering and improve pagination handling

This commit is contained in:
Yoga Pangestu 2026-09-04 15:20:16 +07:00
parent 892d14c13a
commit b99876fb4c
6 changed files with 184 additions and 15 deletions

View File

@ -23,15 +23,27 @@ public function __construct(
public function index(PaginatedRequest $request): Response 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', [ return Inertia::render('admin/academic-classes/assignments/index', [
'assignments' => Inertia::scroll(fn () => $this->service->paginated( 'assignments' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(), $request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'), courseClassId: $request->validated('course_class_id'),
academicTermId: $this->academicTermService->getActive()?->id, academicTermId: $academicTermId,
)), )),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), '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),
]),
]); ]);
} }

View File

@ -23,15 +23,27 @@ public function __construct(
public function index(PaginatedRequest $request): Response 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', [ return Inertia::render('admin/academic-classes/materials/index', [
'materials' => Inertia::scroll(fn () => $this->service->paginated( 'materials' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(), $request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'), courseClassId: $request->validated('course_class_id'),
academicTermId: $this->academicTermService->getActive()?->id, academicTermId: $academicTermId,
)), )),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), '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),
]),
]); ]);
} }

View File

@ -7,6 +7,7 @@
use App\Models\User; use App\Models\User;
use App\Services\Admin\Master\AcademicTermService; use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -116,8 +117,29 @@ public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademic
public function getAllForSelect(User $user): Collection public function getAllForSelect(User $user): Collection
{ {
$isLecturer = $user->hasRole(UserRole::Dosen->value); $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() return CourseClass::query()
->join('courses', 'courses.id', '=', 'course_classes.course_id') ->join('courses', 'courses.id', '=', 'course_classes.course_id')
->join('departments', 'departments.id', '=', 'courses.department_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:id,code,name,semester_number,department_id',
'course.department:id,name', 'course.department:id,name',
]) ])
->when($isLecturer, fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id) ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id))
->where('course_classes.academic_term_id', $activeAcademicTermId)) ->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)) ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
->orderBy('departments.name') ->orderBy('departments.name')
->orderBy('courses.semester_number') ->orderBy('courses.semester_number')
->orderBy('courses.name') ->orderBy('courses.name');
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
} }
} }

View File

@ -45,6 +45,7 @@ export type FilterField =
placeholder?: string; placeholder?: string;
type?: 'select'; type?: 'select';
options: FilterOption[]; options: FilterOption[];
disabled?: boolean;
} }
| { | {
key: string; key: string;
@ -52,6 +53,7 @@ export type FilterField =
placeholder?: string; placeholder?: string;
type: 'combobox'; type: 'combobox';
groups: FilterOptionGroup[]; groups: FilterOptionGroup[];
disabled?: boolean;
}; };
type FilterDialogProps = { type FilterDialogProps = {
@ -85,10 +87,12 @@ function ComboboxFilterField({
isItemEqualToValue={(a: FilterOption, b: FilterOption) => isItemEqualToValue={(a: FilterOption, b: FilterOption) =>
a.value === b.value a.value === b.value
} }
disabled={field.disabled}
> >
<ComboboxInput <ComboboxInput
placeholder={field.placeholder ?? 'Semua'} placeholder={field.placeholder ?? 'Semua'}
showClear showClear
disabled={field.disabled}
className="w-full" className="w-full"
/> />
<ComboboxContent> <ComboboxContent>
@ -190,6 +194,7 @@ export function FilterDialog({
onValueChange={(value) => onValueChange={(value) =>
handleChange(field.key, value) handleChange(field.key, value)
} }
disabled={field.disabled}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue <SelectValue

View File

@ -45,6 +45,7 @@ import {
submit, submit,
update, update,
} from '@/routes/admin/academic-classes/assignments'; } from '@/routes/admin/academic-classes/assignments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Assignment } from '@/types/assignment'; import type { Assignment } from '@/types/assignment';
import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment'; import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
import { createAssignmentCard } from './card'; import { createAssignmentCard } from './card';
@ -63,6 +64,12 @@ type CourseClassOption = {
type CourseClassGroup = { value: string; items: CourseClassOption[] }; type CourseClassGroup = { value: string; items: CourseClassOption[] };
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type Props = { type Props = {
assignments: { assignments: {
data: Assignment[]; data: Assignment[];
@ -72,9 +79,12 @@ type Props = {
total: number; total: number;
}; };
courseClasses: CourseClassOption[]; courseClasses: CourseClassOption[];
filterCourseClasses: CourseClassOption[];
academicTerms: AcademicTermOption[];
highlight?: number; highlight?: number;
filters: { filters: {
course_class_id?: string; course_class_id?: string;
academic_term_id?: string;
}; };
}; };
@ -161,6 +171,8 @@ function CourseClassField({
export default function AssignmentIndex({ export default function AssignmentIndex({
assignments, assignments,
courseClasses, courseClasses,
filterCourseClasses,
academicTerms,
highlight, highlight,
filters, filters,
}: Props) { }: Props) {
@ -177,11 +189,24 @@ export default function AssignmentIndex({
const canSubmit = hasPermission('submit-assignments'); const canSubmit = hasPermission('submit-assignments');
const filterFields = [ const filterFields = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
{ {
key: 'course_class_id', key: 'course_class_id',
label: 'Kelas', label: 'Kelas',
type: 'combobox' as const, 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'], resetKeys: ['assignments'],
}); });
function handleApplyFilters(newFilters: Record<string, string>) {
// 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<string, string> = {
...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() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -233,8 +294,8 @@ export default function AssignmentIndex({
<> <>
<FilterDialog <FilterDialog
fields={filterFields} fields={filterFields}
activeFilters={filters} activeFilters={filterDialogActiveFilters}
onApply={applyFilters} onApply={handleApplyFilters}
/> />
<ViewToggle value={view} onChange={setView} /> <ViewToggle value={view} onChange={setView} />
</> </>

View File

@ -36,6 +36,7 @@ import {
store, store,
update, update,
} from '@/routes/admin/academic-classes/materials'; } from '@/routes/admin/academic-classes/materials';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Material } from '@/types/material'; import type { Material } from '@/types/material';
import { createMaterialCard } from './card'; import { createMaterialCard } from './card';
import { createMaterialColumns } from './columns'; import { createMaterialColumns } from './columns';
@ -53,6 +54,12 @@ type CourseClassOption = {
type CourseClassGroup = { value: string; items: CourseClassOption[] }; type CourseClassGroup = { value: string; items: CourseClassOption[] };
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type Props = { type Props = {
materials: { materials: {
data: Material[]; data: Material[];
@ -62,9 +69,12 @@ type Props = {
total: number; total: number;
}; };
courseClasses: CourseClassOption[]; courseClasses: CourseClassOption[];
filterCourseClasses: CourseClassOption[];
academicTerms: AcademicTermOption[];
highlight?: number; highlight?: number;
filters: { filters: {
course_class_id?: string; course_class_id?: string;
academic_term_id?: string;
}; };
}; };
@ -151,6 +161,8 @@ function courseClassFilterGroups(
export default function MaterialIndex({ export default function MaterialIndex({
materials, materials,
courseClasses, courseClasses,
filterCourseClasses,
academicTerms,
highlight, highlight,
filters, filters,
}: Props) { }: Props) {
@ -164,11 +176,24 @@ export default function MaterialIndex({
const canDelete = hasPermission('delete-materials'); const canDelete = hasPermission('delete-materials');
const filterFields = [ const filterFields = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
{ {
key: 'course_class_id', key: 'course_class_id',
label: 'Kelas', label: 'Kelas',
type: 'combobox' as const, 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'], resetKeys: ['materials'],
}); });
function handleApplyFilters(newFilters: Record<string, string>) {
const clearedAcademicTerm =
Boolean(filters.academic_term_id) && !newFilters.academic_term_id;
const nextFilters: Record<string, string> = {
...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() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -214,8 +272,8 @@ export default function MaterialIndex({
<> <>
<FilterDialog <FilterDialog
fields={filterFields} fields={filterFields}
activeFilters={filters} activeFilters={filterDialogActiveFilters}
onApply={applyFilters} onApply={handleApplyFilters}
/> />
<ViewToggle value={view} onChange={setView} /> <ViewToggle value={view} onChange={setView} />
</> </>