feat: enhance assignment and material management; add academic term filtering and improve pagination handling
This commit is contained in:
parent
892d14c13a
commit
b99876fb4c
@ -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),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user