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
{
$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),
]),
]);
}

View File

@ -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),
]),
]);
}

View File

@ -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');
}
}

View File

@ -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}
>
<ComboboxInput
placeholder={field.placeholder ?? 'Semua'}
showClear
disabled={field.disabled}
className="w-full"
/>
<ComboboxContent>
@ -190,6 +194,7 @@ export function FilterDialog({
onValueChange={(value) =>
handleChange(field.key, value)
}
disabled={field.disabled}
>
<SelectTrigger className="w-full">
<SelectValue

View File

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

View File

@ -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<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() {
if (!deleting) {
return;
@ -214,8 +272,8 @@ export default function MaterialIndex({
<>
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
activeFilters={filterDialogActiveFilters}
onApply={handleApplyFilters}
/>
<ViewToggle value={view} onChange={setView} />
</>