itmpwk.ac.id/resources/js/pages/admin/manage/course-classes/index.tsx

922 lines
37 KiB
TypeScript

import { Head, router } from '@inertiajs/react';
import { Copy, Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
useComboboxAnchor,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as courseClassIndex,
destroy,
duplicate,
store,
update,
} from '@/routes/admin/manage/course-classes';
import { formatAcademicTermLabel } from '@/types/academic-term';
import { ClassMethodLabels, ClassMethods } from '@/types/course-class';
import type { CourseClass } from '@/types/course-class';
import { createCourseClassColumns } from './columns';
type Course = {
id: number;
code: string;
name: string;
department_id: number;
department: { id: number; name: string } | null;
semester_number: number | null;
};
type CourseGroup = { value: string; items: Course[] };
type Lecturer = {
id: number;
lecturer_number: string;
departments: { id: number; name: string }[];
user: { profile: { full_name: string } | null } | null;
};
type AcademicTerm = {
id: number;
academic_year: string;
semester: string;
};
type CourseClassAssignment = {
id: number;
course_id: number;
academic_term_id: number;
};
function courseMatchesTermParity(
course: Course,
term: AcademicTerm | undefined,
): boolean {
if (!term || course.semester_number === null) {
return true;
}
const isOddSemester = course.semester_number % 2 === 1;
return term.semester === 'odd' ? isOddSemester : !isOddSemester;
}
function groupCoursesByDepartmentAndSemester(courses: Course[]): CourseGroup[] {
const groups: CourseGroup[] = [];
let currentKey: string | null = null;
for (const course of courses) {
const key = `${course.department?.name ?? 'Tanpa Jurusan'} — Semester ${course.semester_number ?? 'Tidak ditentukan'}`;
if (key !== currentKey) {
currentKey = key;
groups.push({ value: key, items: [] });
}
groups[groups.length - 1].items.push(course);
}
return groups;
}
type Props = {
courseClasses: {
data: CourseClass[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
highlight?: number;
filters: {
academic_term_id?: string;
method?: string;
};
};
export default function CourseClassIndex({
courseClasses,
courses,
lecturers,
academicTerms,
courseClassAssignments,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [editing, setEditing] = useState<CourseClass | null>(null);
const [deleting, setDeleting] = useState<CourseClass | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-course-classes');
const canUpdate = hasPermission('update-course-classes');
const canDelete = hasPermission('delete-course-classes');
const canViewEnrollments = hasPermission('view-course-class-enrollments');
const filterFields: FilterField[] = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
{
key: 'method',
label: 'Metode',
options: ClassMethods.map((method) => ({
value: method,
label: ClassMethodLabels[method],
})),
},
];
const pagination: PaginationState = {
current_page: courseClasses.current_page,
last_page: courseClasses.last_page,
per_page: courseClasses.per_page,
total: courseClasses.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => courseClassIndex.url(),
pagination,
filters,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createCourseClassColumns({
handleEdit: (courseClass) => setEditing(courseClass),
handleDeleteClick: (courseClass) => setDeleting(courseClass),
canUpdate,
canDelete,
canViewEnrollments,
});
return (
<>
<Head title="Kelas Mata Kuliah" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Kelas Mata Kuliah"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan kelas mata kuliah dari notifikasi.
<button
onClick={() => {
router.get(
courseClassIndex.url(),
{},
{
replace: true,
preserveState: true,
},
);
}}
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
Tampilkan semua
</button>
</p>
)
}
actions={
canCreate && (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDuplicateOpen(true)}
>
<Copy className="h-4 w-4" />
Duplikat dari Periode
</Button>
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
</div>
)
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<DuplicateForm
open={duplicateOpen}
onOpenChange={setDuplicateOpen}
academicTerms={academicTerms}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
courses={courses}
lecturers={lecturers}
academicTerms={academicTerms}
courseClassAssignments={courseClassAssignments}
/>
<DataTable
columns={columns}
data={courseClasses.data}
searchKey="course"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
groupBy={(courseClass) =>
`${courseClass.academic_term ? formatAcademicTermLabel(courseClass.academic_term) : 'Tanpa Periode'} — Semester ${courseClass.course?.semester_number ?? 'Tidak ditentukan'}`
}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Kelas Mata Kuliah"
description={(courseClass) =>
`Apakah Anda yakin ingin menghapus kelas "${courseClass.course?.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [academicTermId, setAcademicTermId] = useState('');
const [selectedCourses, setSelectedCourses] = useState<Course[]>([]);
const [lecturer, setLecturer] = useState<Lecturer | null>(null);
const courseAnchor = useComboboxAnchor();
const selectedTerm = academicTerms.find(
(term) => String(term.id) === academicTermId,
);
const takenCourseIds = new Set(
courseClassAssignments
.filter((a) => String(a.academic_term_id) === academicTermId)
.map((a) => a.course_id),
);
const selectedCourseIds = new Set(selectedCourses.map((c) => c.id));
const lecturerDepartmentIds = new Set(
lecturer ? lecturer.departments.map((d) => d.id) : [],
);
const availableCourses = lecturer
? courses.filter(
(c) =>
lecturerDepartmentIds.has(c.department_id) &&
!takenCourseIds.has(c.id) &&
!selectedCourseIds.has(c.id) &&
courseMatchesTermParity(c, selectedTerm),
)
: [];
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
function reset() {
setAcademicTermId('');
setSelectedCourses([]);
setLecturer(null);
}
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Kelas Mata Kuliah"
action={store()}
resetOnSuccess
onSuccess={() => {
onOpenChange(false);
reset();
}}
>
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={(value) => {
setAcademicTermId(value);
setSelectedCourses([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2">
<Label>
Dosen Pengampu{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="lecturer_id"
value={lecturer?.id ?? ''}
/>
<Combobox
items={lecturers}
value={lecturer}
onValueChange={(value) => {
setLecturer(value);
setSelectedCourses([]);
}}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxInput
placeholder="Pilih dosen pengampu"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Dosen tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(lect: Lecturer) => (
<ComboboxItem
key={lect.id}
value={lect}
>
{lect.user?.profile?.full_name ??
'N/A'}{' '}
- {lect.lecturer_number}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.lecturer_id} />
</div>
<div className="grid gap-2">
<Label>
Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
{selectedCourses.map((c) => (
<input
key={c.id}
type="hidden"
name="course_ids[]"
value={c.id}
/>
))}
<Combobox
items={courseGroups}
multiple
value={selectedCourses}
onValueChange={setSelectedCourses}
itemToStringLabel={(c) => `${c.code} - ${c.name}`}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!lecturer}
>
<ComboboxChips ref={courseAnchor}>
{selectedCourses.map((c) => (
<ComboboxChip
key={c.id}
aria-label={`${c.code} - ${c.name}`}
>
{c.code}
</ComboboxChip>
))}
<ComboboxChipsInput
disabled={!lecturer}
placeholder={
!lecturer
? 'Pilih dosen pengampu terlebih dahulu'
: selectedCourses.length === 0
? 'Pilih mata kuliah (bisa lebih dari satu)'
: ''
}
/>
</ComboboxChips>
<ComboboxContent anchor={courseAnchor}>
<ComboboxEmpty>
Mata kuliah tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(group: CourseGroup) => (
<ComboboxGroup
key={group.value}
items={group.items}
>
<ComboboxLabel>
{group.value}
</ComboboxLabel>
<ComboboxCollection>
{(c: Course) => (
<ComboboxItem
key={c.id}
value={c}
>
{c.code} - {c.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.course_ids} />
</div>
<div className="grid gap-2">
<Label>Metode</Label>
<input type="hidden" name="method" />
<Select name="method" defaultValue="hybrid">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih metode" />
</SelectTrigger>
<SelectContent>
{ClassMethods.map((method) => (
<SelectItem key={method} value={method}>
{ClassMethodLabels[method]}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.method} />
</div>
</div>
)}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
editing,
courses,
lecturers,
academicTerms,
courseClassAssignments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: CourseClass | null;
courses: Course[];
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
courseClassAssignments: CourseClassAssignment[];
}) {
const [academicTermId, setAcademicTermId] = useState(
editing ? String(editing.academic_term_id) : '',
);
const [course, setCourse] = useState<Course | null>(
editing
? (courses.find((c) => c.id === editing.course_id) ?? null)
: null,
);
const [lecturer, setLecturer] = useState<Lecturer | null>(
editing
? (lecturers.find((l) => l.id === editing.lecturer_id) ?? null)
: null,
);
const selectedTerm = academicTerms.find(
(term) => String(term.id) === academicTermId,
);
const takenCourseIds = new Set(
courseClassAssignments
.filter(
(a) =>
String(a.academic_term_id) === academicTermId &&
a.id !== editing?.id,
)
.map((a) => a.course_id),
);
const lecturerDepartmentIds = new Set(
lecturer ? lecturer.departments.map((d) => d.id) : [],
);
const availableCourses = lecturer
? courses.filter(
(c) =>
lecturerDepartmentIds.has(c.department_id) &&
!takenCourseIds.has(c.id) &&
courseMatchesTermParity(c, selectedTerm),
)
: [];
const courseGroups = groupCoursesByDepartmentAndSemester(availableCourses);
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Kelas Mata Kuliah"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={(value) => {
setAcademicTermId(value);
setCourse(null);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2">
<Label>
Dosen Pengampu{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="lecturer_id"
value={lecturer?.id ?? ''}
/>
<Combobox
items={lecturers}
value={lecturer}
onValueChange={(value) => {
setLecturer(value);
setCourse(null);
}}
itemToStringLabel={(lect) =>
`${lect.user?.profile?.full_name ?? 'N/A'} - ${lect.lecturer_number}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxInput
placeholder="Pilih dosen pengampu"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Dosen tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(lect: Lecturer) => (
<ComboboxItem
key={lect.id}
value={lect}
>
{lect.user?.profile
?.full_name ?? 'N/A'}{' '}
- {lect.lecturer_number}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.lecturer_id} />
</div>
<div className="grid gap-2">
<Label>
Mata Kuliah{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="course_id"
value={course?.id ?? ''}
/>
<Combobox
items={courseGroups}
value={course}
onValueChange={setCourse}
itemToStringLabel={(c) =>
`${c.code} - ${c.name}`
}
isItemEqualToValue={(a, b) => a.id === b.id}
disabled={!lecturer}
>
<ComboboxInput
disabled={!lecturer}
placeholder={
lecturer
? 'Pilih mata kuliah'
: 'Pilih dosen pengampu terlebih dahulu'
}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Mata kuliah tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(group: CourseGroup) => (
<ComboboxGroup
key={group.value}
items={group.items}
>
<ComboboxLabel>
{group.value}
</ComboboxLabel>
<ComboboxCollection>
{(c: Course) => (
<ComboboxItem
key={c.id}
value={c}
>
{c.code} -{' '}
{c.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.course_id} />
</div>
<div className="grid gap-2">
<Label>Metode</Label>
<Select
name="method"
defaultValue={editing.method ?? 'hybrid'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih metode" />
</SelectTrigger>
<SelectContent>
{ClassMethods.map((method) => (
<SelectItem key={method} value={method}>
{ClassMethodLabels[method]}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.method} />
</div>
</div>
)
}
</FormDialog>
);
}
function DuplicateForm({
open,
onOpenChange,
academicTerms,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
academicTerms: AcademicTerm[];
}) {
const [sourceTermId, setSourceTermId] = useState('');
const [targetTermId, setTargetTermId] = useState('');
const sourceTerm = academicTerms.find(
(term) => String(term.id) === sourceTermId,
);
const targetOptions = academicTerms.filter(
(term) =>
String(term.id) !== sourceTermId &&
(!sourceTerm || term.semester === sourceTerm.semester),
);
function reset() {
setSourceTermId('');
setTargetTermId('');
}
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Duplikat Kelas Mata Kuliah"
action={duplicate()}
resetOnSuccess
submitLabel="Duplikat"
submittingLabel="Menduplikasi..."
onSuccess={() => {
onOpenChange(false);
reset();
}}
>
{({ errors }) => (
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
Menyalin semua kelas mata kuliah (mata kuliah, dosen
pengampu, metode) dari periode sumber ke periode
tujuan. Periode tujuan hanya bisa dipilih dari
semester yang sama (Ganjil ke Ganjil, Genap ke Genap).
Mata kuliah yang sudah punya kelas di periode tujuan
akan dilewati.
</p>
<div className="grid gap-2">
<Label>
Dari Periode{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="source_academic_term_id"
value={sourceTermId}
/>
<Select
value={sourceTermId}
onValueChange={(value) => {
setSourceTermId(value);
setTargetTermId('');
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode sumber" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError
message={errors.source_academic_term_id}
/>
</div>
<div className="grid gap-2">
<Label>
Ke Periode{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="target_academic_term_id"
value={targetTermId}
/>
<Select
value={targetTermId}
onValueChange={setTargetTermId}
disabled={!sourceTermId}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
sourceTermId
? 'Pilih periode tujuan'
: 'Pilih periode sumber terlebih dahulu'
}
/>
</SelectTrigger>
<SelectContent>
{targetOptions.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError
message={errors.target_academic_term_id}
/>
</div>
</div>
)}
</FormDialog>
);
}