diff --git a/src/components/High/Table.tsx b/src/components/High/Table.tsx index 64e166ef..479e614c 100644 --- a/src/components/High/Table.tsx +++ b/src/components/High/Table.tsx @@ -1,7 +1,7 @@ import { useListSearch } from "@/hooks/useListSearch" import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table" import clsx from "clsx" -import { useState } from "react" +import { useEffect, useState } from "react" import { BsArrowDown, BsArrowUp } from "react-icons/bs" import Button from "../Low/Button" diff --git a/src/components/Low/Select.tsx b/src/components/Low/Select.tsx index bdc9c68a..c666cbe8 100644 --- a/src/components/Low/Select.tsx +++ b/src/components/Low/Select.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; -import {ComponentProps, useEffect, useState} from "react"; -import ReactSelect, {GroupBase, StylesConfig} from "react-select"; +import { ComponentProps, useEffect, useState } from "react"; +import ReactSelect, { GroupBase, StylesConfig } from "react-select"; import Option from "@/interfaces/option"; interface Props { @@ -9,14 +9,23 @@ interface Props { options: Option[]; disabled?: boolean; placeholder?: string; - onChange: (value: Option | null) => void; isClearable?: boolean; styles?: StylesConfig>; className?: string; label?: string; } -export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, label, className}: Props) { +interface MultiProps { + isMulti: true + onChange: (value: Option[] | null) => void +} + +interface SingleProps { + isMulti?: false + onChange: (value: Option | null) => void +} + +export default function Select({ value, isMulti, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, label, className }: Props & (MultiProps | SingleProps)) { const [target, setTarget] = useState(); useEffect(() => { @@ -27,14 +36,15 @@ export default function Select({value, defaultValue, options, placeholder, disab
{label && } ({...base, zIndex: 9999}), + menuPortal: (base) => ({ ...base, zIndex: 9999 }), control: (styles) => ({ ...styles, paddingLeft: "4px", diff --git a/src/components/Medium/StatGridItem.tsx b/src/components/Medium/StatGridItem.tsx index bf7bddef..aa025517 100644 --- a/src/components/Medium/StatGridItem.tsx +++ b/src/components/Medium/StatGridItem.tsx @@ -260,9 +260,13 @@ const StatsGridItem: React.FC = ({
- {!!assignment && - (assignment.released || assignment.released === undefined) && - aggregatedLevels.map(({ module, level }) => )} + {aggregatedLevels.map(({ module, level }) => + + )}
{assignment && ( diff --git a/src/components/PracticeModal.tsx b/src/components/PracticeModal.tsx new file mode 100644 index 00000000..82ebc779 --- /dev/null +++ b/src/components/PracticeModal.tsx @@ -0,0 +1,26 @@ +import { useState } from "react"; +import Button from "./Low/Button"; +import Modal from "./Modal"; + +interface Props { + open?: boolean +} + +export default function PracticeModal({ open }: Props) { + const [isOpen, setIsOpen] = useState(open || false) + + return ( + setIsOpen(false)}> +
+ + To acquaint yourself with the question types in this section, please respond to the practice questions provided. +
+ Do note that these questions are for practice purposes only and are not graded. +
+ You may choose to skip them if you prefer. +
+ +
+
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 74cc576b..f89c97b9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -171,16 +171,17 @@ export default function Sidebar({ badge={totalAssignedTickets} /> )} - {(entitiesAllowGeneration.length > 0 || isAdmin) && ( -
: ( <> +
{/* setShowTextModal(false)} />*/} diff --git a/src/exams/Speaking.tsx b/src/exams/Speaking.tsx index 4205ee87..4801a931 100644 --- a/src/exams/Speaking.tsx +++ b/src/exams/Speaking.tsx @@ -13,6 +13,7 @@ import useExamNavigation from "./Navigation/useExamNavigation"; import ProgressButtons from "./components/ProgressButtons"; import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex"; import SectionNavbar from "./Navigation/SectionNavbar"; +import PracticeModal from "@/components/PracticeModal"; const Speaking: React.FC> = ({ exam, showSolutions = false, preview = false }) => { @@ -33,6 +34,7 @@ const Speaking: React.FC> = ({ exam, showSolutions = fal const { finalizeModule, timeIsUp } = flags; const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60); + const hasPractice = useMemo(() => exam.exercises.some(e => e.isPractice), [exam.exercises]) const { nextExercise, previousExercise, @@ -110,6 +112,7 @@ const Speaking: React.FC> = ({ exam, showSolutions = fal onNext={handlePartDividerClick} /> : ( <> + {exam.exercises.length > 1 && > = ({ exam, showSolutions = false, preview = false }) => { const updateTimers = useExamTimer(exam.module, preview || showSolutions); @@ -27,6 +28,7 @@ const Writing: React.FC> = ({ exam, showSolutions = false } = !preview ? examState : persistentExamState; const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60); + const hasPractice = useMemo(() => exam.exercises.some(e => e.isPractice), [exam.exercises]) const { finalizeModule, timeIsUp } = flags; const { nextDisabled } = navigation; @@ -80,8 +82,8 @@ const Writing: React.FC> = ({ exam, showSolutions = false const progressButtons = useMemo(() => // Do not remove the ()=> in handle next - nextExercise()} nextDisabled={nextDisabled}/> - , [nextExercise, previousExercise, nextDisabled]); + nextExercise()} nextDisabled={nextDisabled} /> + , [nextExercise, previousExercise, nextDisabled]); return ( @@ -96,6 +98,7 @@ const Writing: React.FC> = ({ exam, showSolutions = false onNext={handlePartDividerClick} /> : (
+ {exam.exercises.length > 1 && ([]); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); @@ -10,12 +10,12 @@ export default function useCodes(creator?: string) { const getData = () => { setIsLoading(true); axios - .get(`/api/code${creator ? `?creator=${creator}` : ""}`) + .get(`/api/code${entity ? `?entity=${entity}` : ""}`) .then((response) => setCodes(response.data)) .finally(() => setIsLoading(false)); }; - useEffect(getData, [creator]); + useEffect(getData, [entity]); - return {codes, isLoading, isError, reload: getData}; + return { codes, isLoading, isError, reload: getData }; } diff --git a/src/hooks/useEntitiesCodes.tsx b/src/hooks/useEntitiesCodes.tsx new file mode 100644 index 00000000..e286b6a7 --- /dev/null +++ b/src/hooks/useEntitiesCodes.tsx @@ -0,0 +1,25 @@ +import { Code, Group, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useEntitiesCodes(entities?: string[]) { + const [codes, setCodes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + const params = new URLSearchParams() + if (entities) + entities.forEach(e => params.append("entities", e)) + + axios + .get(`/api/code/entities${entities ? `?${params.toString()}` : ""}`) + .then((response) => setCodes(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, [entities]); + + return { codes, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useSessions.tsx b/src/hooks/useSessions.tsx index 756f08bb..2718786c 100644 --- a/src/hooks/useSessions.tsx +++ b/src/hooks/useSessions.tsx @@ -1,8 +1,8 @@ -import {ExamState} from "@/stores/exam/types"; +import { ExamState } from "@/stores/exam/types"; import axios from "axios"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; -export type Session = ExamState & {user: string; id: string; date: string}; +export type Session = ExamState & { user: string; id: string; date: string }; export default function useSessions(user?: string) { const [sessions, setSessions] = useState([]); @@ -19,5 +19,5 @@ export default function useSessions(user?: string) { useEffect(getData, [user]); - return {sessions, isLoading, isError, reload: getData}; + return { sessions, isLoading, isError, reload: getData }; } diff --git a/src/interfaces/entity.ts b/src/interfaces/entity.ts index 3cb45e74..da1e8e5e 100644 --- a/src/interfaces/entity.ts +++ b/src/interfaces/entity.ts @@ -3,7 +3,7 @@ import { RolePermission } from "@/resources/entityPermissions"; export interface Entity { id: string; label: string; - licenses: number + licenses: number; } export interface Role { diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index db6ae044..8de4bf56 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -155,6 +155,7 @@ export interface GroupWithUsers extends Omit { export interface Code { id: string; code: string; + entity: string creator: string; expiryDate: Date; type: Type; diff --git a/src/pages/(admin)/BatchCodeGenerator.tsx b/src/pages/(admin)/BatchCodeGenerator.tsx index f10dc2c1..166103c1 100644 --- a/src/pages/(admin)/BatchCodeGenerator.tsx +++ b/src/pages/(admin)/BatchCodeGenerator.tsx @@ -1,29 +1,31 @@ import Button from "@/components/Low/Button"; import Checkbox from "@/components/Low/Checkbox"; -import {PERMISSIONS} from "@/constants/userPermissions"; +import { PERMISSIONS } from "@/constants/userPermissions"; import useUsers from "@/hooks/useUsers"; -import {Type, User} from "@/interfaces/user"; -import {USER_TYPE_LABELS} from "@/resources/user"; +import { Type, User } from "@/interfaces/user"; +import { USER_TYPE_LABELS } from "@/resources/user"; import axios from "axios"; import clsx from "clsx"; -import {capitalize, uniqBy} from "lodash"; +import { capitalize, uniqBy } from "lodash"; import moment from "moment"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import ReactDatePicker from "react-datepicker"; -import {toast} from "react-toastify"; +import { toast } from "react-toastify"; import ShortUniqueId from "short-unique-id"; -import {useFilePicker} from "use-file-picker"; +import { useFilePicker } from "use-file-picker"; import readXlsxFile from "read-excel-file"; import Modal from "@/components/Modal"; -import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; -import {PermissionType} from "@/interfaces/permissions"; +import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs"; +import { checkAccess, getTypesOfUser } from "@/utils/permissions"; +import { PermissionType } from "@/interfaces/permissions"; import usePermissions from "@/hooks/usePermissions"; +import { EntityWithRoles } from "@/interfaces/entity"; +import Select from "@/components/Low/Select"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const USER_TYPE_PERMISSIONS: { - [key in Type]: {perm: PermissionType | undefined; list: Type[]}; + [key in Type]: { perm: PermissionType | undefined; list: Type[] }; } = { student: { perm: "createCodeStudent", @@ -59,11 +61,12 @@ interface Props { user: User; users: User[]; permissions: PermissionType[]; + entities: EntityWithRoles[] onFinish: () => void; } -export default function BatchCodeGenerator({user, users, permissions, onFinish}: Props) { - const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); +export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) { + const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [expiryDate, setExpiryDate] = useState( user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, @@ -71,8 +74,9 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}: const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [type, setType] = useState("student"); const [showHelp, setShowHelp] = useState(false); + const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) - const {openFilePicker, filesContent, clear} = useFilePicker({ + const { openFilePicker, filesContent, clear } = useFilePicker({ accept: ".xlsx", multiple: false, readAs: "ArrayBuffer", @@ -93,10 +97,10 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}: const [firstName, lastName, country, passport_id, email, phone] = row as string[]; return EMAIL_REGEX.test(email.toString().trim()) ? { - email: email.toString().trim().toLowerCase(), - name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), - passport_id: passport_id?.toString().trim() || undefined, - } + email: email.toString().trim().toLowerCase(), + name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), + passport_id: passport_id?.toString().trim() || undefined, + } : undefined; }) .filter((x) => !!x) as typeof infos, @@ -139,7 +143,7 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}: return; setIsLoading(true); - Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id}))) + Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id }))) .then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`)) .finally(() => { if (newUsers.length === 0) setIsLoading(false); @@ -155,19 +159,20 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}: setIsLoading(true); axios - .post<{ok: boolean; valid?: number; reason?: string}>("/api/code", { + .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", { type, codes, - infos: informations, + infos: informations.map((info, index) => ({ ...info, code: codes[index] })), expiryDate, + entity }) - .then(({data, status}) => { + .then(({ data, status }) => { if (data.ok) { toast.success( `Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize( type, )} codes and they have been notified by e-mail!`, - {toastId: "success"}, + { toastId: "success" }, ); onFinish(); @@ -175,12 +180,12 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}: } if (status === 403) { - toast.error(data.reason, {toastId: "forbidden"}); + toast.error(data.reason, { toastId: "forbidden" }); } }) - .catch(({response: {status, data}}) => { + .catch(({ response: { status, data } }) => { if (status === 403) { - toast.error(data.reason, {toastId: "forbidden"}); + toast.error(data.reason, { toastId: "forbidden" }); return; } @@ -258,6 +263,15 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}: )} )} +
+ + {Object.keys(USER_TYPE_LABELS) .filter((x) => { - const {list, perm} = USER_TYPE_PERMISSIONS[x as Type]; + const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; return checkAccess(user, getTypesOfUser(list), permissions, perm); }) .map((type) => ( diff --git a/src/pages/(admin)/CodeGenerator.tsx b/src/pages/(admin)/CodeGenerator.tsx index c1799783..ce65d719 100644 --- a/src/pages/(admin)/CodeGenerator.tsx +++ b/src/pages/(admin)/CodeGenerator.tsx @@ -1,22 +1,25 @@ import Button from "@/components/Low/Button"; import Checkbox from "@/components/Low/Checkbox"; -import {PERMISSIONS} from "@/constants/userPermissions"; -import {Type, User} from "@/interfaces/user"; -import {USER_TYPE_LABELS} from "@/resources/user"; +import { PERMISSIONS } from "@/constants/userPermissions"; +import { Type, User } from "@/interfaces/user"; +import { USER_TYPE_LABELS } from "@/resources/user"; import axios from "axios"; import clsx from "clsx"; -import {capitalize} from "lodash"; +import { capitalize } from "lodash"; import moment from "moment"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import ReactDatePicker from "react-datepicker"; -import {toast} from "react-toastify"; +import { toast } from "react-toastify"; import ShortUniqueId from "short-unique-id"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; -import {PermissionType} from "@/interfaces/permissions"; +import { checkAccess, getTypesOfUser } from "@/utils/permissions"; +import { PermissionType } from "@/interfaces/permissions"; import usePermissions from "@/hooks/usePermissions"; +import { EntityWithRoles } from "@/interfaces/entity"; +import Select from "@/components/Low/Select"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; const USER_TYPE_PERMISSIONS: { - [key in Type]: {perm: PermissionType | undefined; list: Type[]}; + [key in Type]: { perm: PermissionType | undefined; list: Type[] }; } = { student: { perm: "createCodeStudent", @@ -51,16 +54,19 @@ const USER_TYPE_PERMISSIONS: { interface Props { user: User; permissions: PermissionType[]; + entities: EntityWithRoles[] onFinish: () => void; } -export default function CodeGenerator({user, permissions, onFinish}: Props) { +export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) { const [generatedCode, setGeneratedCode] = useState(); + const [expiryDate, setExpiryDate] = useState( user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, ); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [type, setType] = useState("student"); + const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) useEffect(() => { if (!isExpiryDateEnabled) setExpiryDate(null); @@ -71,8 +77,8 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) { const code = uid.randomUUID(6); axios - .post("/api/code", {type, codes: [code], expiryDate}) - .then(({data, status}) => { + .post("/api/code", { type, codes: [code], expiryDate, entity }) + .then(({ data, status }) => { if (data.ok) { toast.success(`Successfully generated a ${capitalize(type)} code!`, { toastId: "success", @@ -82,12 +88,12 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) { } if (status === 403) { - toast.error(data.reason, {toastId: "forbidden"}); + toast.error(data.reason, { toastId: "forbidden" }); } }) - .catch(({response: {status, data}}) => { + .catch(({ response: { status, data } }) => { if (status === 403) { - toast.error(data.reason, {toastId: "forbidden"}); + toast.error(data.reason, { toastId: "forbidden" }); return; } @@ -100,14 +106,25 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) { return (
- {user && ( +
+ + setType(e.target.value as typeof user.type)} className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> {Object.keys(USER_TYPE_LABELS) .filter((x) => { - const {list, perm} = USER_TYPE_PERMISSIONS[x as Type]; + const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; return checkAccess(user, getTypesOfUser(list), permissions, perm); }) .map((type) => ( @@ -116,8 +133,9 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) { ))} - )} - {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( +
+ + {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( <>
diff --git a/src/pages/(admin)/CorporateGradingSystem.tsx b/src/pages/(admin)/CorporateGradingSystem.tsx index a3afe0bb..28c006b6 100644 --- a/src/pages/(admin)/CorporateGradingSystem.tsx +++ b/src/pages/(admin)/CorporateGradingSystem.tsx @@ -1,13 +1,16 @@ import Button from "@/components/Low/Button"; import Input from "@/components/Low/Input"; import Select from "@/components/Low/Select"; +import Separator from "@/components/Low/Separator"; import { Grading, Step } from "@/interfaces"; import { Entity } from "@/interfaces/entity"; import { User } from "@/interfaces/user"; import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading"; +import { mapBy } from "@/utils"; import { checkAccess } from "@/utils/permissions"; import axios from "axios"; import clsx from "clsx"; +import { Divider } from "primereact/divider"; import { useEffect, useState } from "react"; import { BsPlusCircle, BsTrash } from "react-icons/bs"; import { toast } from "react-toastify"; @@ -36,6 +39,7 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined) const [isLoading, setIsLoading] = useState(false); const [steps, setSteps] = useState([]); + const [otherEntities, setOtherEntities] = useState([]) useEffect(() => { if (entity) { @@ -63,6 +67,27 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent .finally(() => setIsLoading(false)); }; + const applyToOtherEntities = () => { + if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold."); + if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps."); + if ( + steps.reduce((acc, curr) => { + return acc - (curr.max - curr.min + 1); + }, 100) > 0 + ) + return toast.error("There seems to be an open interval in your steps."); + + if (otherEntities.length === 0) return toast.error("Select at least one entity") + + setIsLoading(true); + axios + .post("/api/grading/multiple", { user: user.id, entities: otherEntities, steps }) + .then(() => toast.success("Your grading system has been saved!")) + .then(mutate) + .catch(() => toast.error("Something went wrong, please try again later")) + .finally(() => setIsLoading(false)); + }; + return (
@@ -76,6 +101,22 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent />
+ {entities.length > 1 && ( + <> + + + ["admin", "developer", "corporate"].includes(x.type)) - .map((x) => ({ - label: `${x.name} (${USER_TYPE_LABELS[x.type]})`, - value: x.id, - user: x, - }))} - onChange={(value) => setFilteredCorporate(value ? users.find((x) => x.id === value?.value) : undefined)} - /> - ({value: x.id, label: `${x.name} - ${x.email}`}))} - defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} - onChange={(value) => setStatsUserId(value?.value || user.id)} + options={entities.map(e => ({ value: e.id, label: e.label }))} + onChange={(value) => setSelectedEntity(value?.value || undefined)} + placeholder="Select an entity..." + isClearable /> - )} - {["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (