395 lines
11 KiB
TypeScript
395 lines
11 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
import useExams from "@/hooks/useExams";
|
|
import useUsers from "@/hooks/useUsers";
|
|
import { Module } from "@/interfaces";
|
|
import { Exam } from "@/interfaces/exam";
|
|
import { User } from "@/interfaces/user";
|
|
import useExamStore from "@/stores/exam";
|
|
import { getExamById } from "@/utils/exams";
|
|
import { countExercises } from "@/utils/moduleUtils";
|
|
import {
|
|
createColumnHelper,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import axios from "axios";
|
|
import { capitalize } from "lodash";
|
|
import { useRouter } from "next/router";
|
|
import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs";
|
|
import { toast } from "react-toastify";
|
|
import { useListSearch } from "@/hooks/useListSearch";
|
|
import Modal from "@/components/Modal";
|
|
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
|
import Button from "@/components/Low/Button";
|
|
import { EntityWithRoles } from "@/interfaces/entity";
|
|
import { BiEdit } from "react-icons/bi";
|
|
import { findBy, mapBy } from "@/utils";
|
|
|
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
|
|
|
const CLASSES: { [key in Module]: string } = {
|
|
reading: "text-ielts-reading",
|
|
listening: "text-ielts-listening",
|
|
speaking: "text-ielts-speaking",
|
|
writing: "text-ielts-writing",
|
|
level: "text-ielts-level",
|
|
};
|
|
const columnHelper = createColumnHelper<Exam>();
|
|
|
|
export default function ExamList({
|
|
user,
|
|
entities,
|
|
}: {
|
|
user: User;
|
|
entities: EntityWithRoles[];
|
|
}) {
|
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
|
|
|
const canViewConfidentialEntities = useMemo(
|
|
() =>
|
|
mapBy(
|
|
findAllowedEntities(user, entities, "view_confidential_exams"),
|
|
"id"
|
|
),
|
|
[user, entities]
|
|
);
|
|
|
|
const { exams, reload, isLoading } = useExams();
|
|
const { users } = useUsers();
|
|
// Pass this permission filter to the backend later
|
|
const filteredExams = useMemo(
|
|
() =>
|
|
["admin", "developer"].includes(user?.type)
|
|
? exams
|
|
: exams.filter((item) => {
|
|
if (
|
|
item.access === "confidential" &&
|
|
!canViewConfidentialEntities.find((x) =>
|
|
(item.entities ?? []).includes(x)
|
|
)
|
|
)
|
|
return false;
|
|
return true;
|
|
}),
|
|
[canViewConfidentialEntities, exams, user?.type]
|
|
);
|
|
|
|
const parsedExams = useMemo(() => {
|
|
return filteredExams.map((exam) => {
|
|
if (exam.createdBy) {
|
|
const user = users.find((u) => u.id === exam.createdBy);
|
|
if (!user) return exam;
|
|
|
|
return {
|
|
...exam,
|
|
createdBy: user.type === "developer" ? "system" : user.name,
|
|
};
|
|
}
|
|
|
|
return exam;
|
|
});
|
|
}, [filteredExams, users]);
|
|
|
|
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
|
|
searchFields,
|
|
parsedExams
|
|
);
|
|
|
|
const dispatch = useExamStore((state) => state.dispatch);
|
|
|
|
const router = useRouter();
|
|
|
|
const loadExam = async (module: Module, examId: string) => {
|
|
const exam = await getExamById(module, examId.trim());
|
|
if (!exam) {
|
|
toast.error(
|
|
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
|
{
|
|
toastId: "invalid-exam-id",
|
|
}
|
|
);
|
|
|
|
return;
|
|
}
|
|
dispatch({
|
|
type: "INIT_EXAM",
|
|
payload: { exams: [exam], modules: [module] },
|
|
});
|
|
|
|
router.push("/exam");
|
|
};
|
|
|
|
/*
|
|
const privatizeExam = async (exam: Exam) => {
|
|
if (
|
|
!confirm(
|
|
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
|
|
exam.access
|
|
}?`
|
|
)
|
|
)
|
|
return;
|
|
|
|
axios
|
|
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
|
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
|
.catch((reason) => {
|
|
if (reason.response.status === 404) {
|
|
toast.error("Exam not found!");
|
|
return;
|
|
}
|
|
|
|
if (reason.response.status === 403) {
|
|
toast.error("You do not have permission to update this exam!");
|
|
return;
|
|
}
|
|
|
|
toast.error("Something went wrong, please try again later.");
|
|
})
|
|
.finally(reload);
|
|
};
|
|
*/
|
|
|
|
const deleteExam = async (exam: Exam) => {
|
|
if (
|
|
!confirm(
|
|
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
|
)
|
|
)
|
|
return;
|
|
|
|
axios
|
|
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
|
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
|
.catch((reason) => {
|
|
if (reason.response.status === 404) {
|
|
toast.error("Exam not found!");
|
|
return;
|
|
}
|
|
|
|
if (reason.response.status === 403) {
|
|
toast.error("You do not have permission to delete this exam!");
|
|
return;
|
|
}
|
|
|
|
toast.error("Something went wrong, please try again later.");
|
|
})
|
|
.finally(reload);
|
|
};
|
|
|
|
const getTotalExercises = (exam: Exam) => {
|
|
if (
|
|
exam.module === "reading" ||
|
|
exam.module === "listening" ||
|
|
exam.module === "level"
|
|
) {
|
|
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
|
|
}
|
|
|
|
return countExercises(exam.exercises);
|
|
};
|
|
|
|
const defaultColumns = [
|
|
columnHelper.accessor("id", {
|
|
header: "ID",
|
|
cell: (info) => info.getValue(),
|
|
}),
|
|
columnHelper.accessor("module", {
|
|
header: "Module",
|
|
cell: (info) => (
|
|
<span className={CLASSES[info.getValue()]}>
|
|
{capitalize(info.getValue())}
|
|
</span>
|
|
),
|
|
}),
|
|
columnHelper.accessor((x) => getTotalExercises(x), {
|
|
header: "Exercises",
|
|
cell: (info) => info.getValue(),
|
|
}),
|
|
columnHelper.accessor("minTimer", {
|
|
header: "Timer",
|
|
cell: (info) => <>{info.getValue()} minute(s)</>,
|
|
}),
|
|
columnHelper.accessor("access", {
|
|
header: "Access",
|
|
cell: (info) => <span>{capitalize(info.getValue())}</span>,
|
|
}),
|
|
columnHelper.accessor("createdAt", {
|
|
header: "Created At",
|
|
cell: (info) => {
|
|
const value = info.getValue();
|
|
if (value) {
|
|
return new Date(value).toLocaleDateString();
|
|
}
|
|
|
|
return null;
|
|
},
|
|
}),
|
|
columnHelper.accessor("createdBy", {
|
|
header: "Created By",
|
|
cell: (info) =>
|
|
!info.getValue()
|
|
? "System"
|
|
: findBy(users, "id", info.getValue())?.name || "N/A",
|
|
}),
|
|
{
|
|
header: "",
|
|
id: "actions",
|
|
cell: ({ row }: { row: { original: Exam } }) => {
|
|
return (
|
|
<div className="flex gap-4">
|
|
{(row.original.owners?.includes(user.id) ||
|
|
checkAccess(user, ["admin", "developer"])) && (
|
|
<>
|
|
{checkAccess(user, [
|
|
"admin",
|
|
"developer",
|
|
"mastercorporate",
|
|
]) && (
|
|
<button
|
|
data-tip="Edit exam"
|
|
onClick={() => setSelectedExam(row.original)}
|
|
className="cursor-pointer tooltip"
|
|
>
|
|
<BsPencil />
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
<button
|
|
data-tip="Load exam"
|
|
className="cursor-pointer tooltip"
|
|
onClick={async () =>
|
|
await loadExam(row.original.module, row.original.id)
|
|
}
|
|
>
|
|
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
</button>
|
|
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
|
<div
|
|
data-tip="Delete"
|
|
className="cursor-pointer tooltip"
|
|
onClick={() => deleteExam(row.original)}
|
|
>
|
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
const table = useReactTable({
|
|
data: filteredRows,
|
|
columns: defaultColumns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
});
|
|
|
|
const handleExamEdit = () => {
|
|
router.push(
|
|
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 w-full h-full">
|
|
{renderSearch()}
|
|
<Modal
|
|
isOpen={!!selectedExam}
|
|
onClose={() => setSelectedExam(undefined)}
|
|
maxWidth="max-w-xl"
|
|
>
|
|
{!!selectedExam ? (
|
|
<>
|
|
<div className="p-6">
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<BiEdit className="w-5 h-5 text-gray-600" />
|
|
<span className="text-gray-600 font-medium">
|
|
Ready to Edit
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
|
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
|
</div>
|
|
|
|
<p className="text-gray-500 text-sm">
|
|
Click 'Next' to proceed to the exam editor.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-between gap-4 mt-8">
|
|
<Button
|
|
color="purple"
|
|
variant="outline"
|
|
onClick={() => setSelectedExam(undefined)}
|
|
className="w-32"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="purple"
|
|
onClick={handleExamEdit}
|
|
className="w-32 text-white flex items-center justify-center gap-2"
|
|
>
|
|
Proceed
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
|
|
</>
|
|
) : (
|
|
<div />
|
|
)}
|
|
</Modal>
|
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
|
<thead>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => (
|
|
<th className="p-4 text-left" key={header.id}>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext()
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody className="px-2">
|
|
{table.getRowModel().rows.map((row) => (
|
|
<tr
|
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
|
key={row.id}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<td className="px-4 py-2" key={cell.id}>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{isLoading ? (
|
|
<div className="min-h-screen flex justify-center items-start">
|
|
<span className="loading loading-infinity w-32" />
|
|
</div>
|
|
) : (
|
|
filteredRows.length === 0 && (
|
|
<div className="w-full flex justify-center items-start">
|
|
<span className="text-xl text-gray-500">No data found...</span>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|