diff --git a/.gitignore b/.gitignore index 30f9f1ed..46468831 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ next-env.d.ts .env .yarn/* .history* -__ENV.js \ No newline at end of file +__ENV.js + +settings.json \ No newline at end of file diff --git a/package.json b/package.json index b0f35e4c..43102b99 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "country-codes-list": "^1.6.11", "currency-symbol-map": "^5.1.0", "daisyui": "^3.1.5", + "deep-diff": "^1.0.2", "eslint": "8.33.0", "eslint-config-next": "13.1.6", "exceljs": "^4.4.0", @@ -97,6 +98,7 @@ "devDependencies": { "@simbathesailor/use-what-changed": "^2.0.0", "@types/blob-stream": "^0.1.33", + "@types/deep-diff": "^1.0.5", "@types/formidable": "^3.4.0", "@types/howler": "^2.2.11", "@types/lodash": "^4.14.191", diff --git a/src/components/ApprovalWorkflows/RequestedBy.tsx b/src/components/ApprovalWorkflows/RequestedBy.tsx new file mode 100644 index 00000000..036cc968 --- /dev/null +++ b/src/components/ApprovalWorkflows/RequestedBy.tsx @@ -0,0 +1,32 @@ +import Image from "next/image"; +import React from "react"; +import { FaRegUser } from "react-icons/fa"; + +interface Props { + prefix: string; + name: string; + profileImage: string; +} + +export default function RequestedBy({ prefix, name, profileImage }: Props) { + return ( +
+
+ +
+
+

Requested by

+
+

{prefix} {name}

+ {name} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/StartedOn.tsx b/src/components/ApprovalWorkflows/StartedOn.tsx new file mode 100644 index 00000000..2b1fde6d --- /dev/null +++ b/src/components/ApprovalWorkflows/StartedOn.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { PiCalendarDots } from "react-icons/pi"; + +interface Props { + date: number; +} + +export default function StartedOn({ date }: Props) { + const formattedDate = new Date(date); + + const yearMonthDay = formattedDate.toISOString().split("T")[0]; + + const fullDateTime = formattedDate.toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + + return ( +
+
+ +
+
+

Started on

+
+

+ {yearMonthDay} +

+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/Status.tsx b/src/components/ApprovalWorkflows/Status.tsx new file mode 100644 index 00000000..f7176bfa --- /dev/null +++ b/src/components/ApprovalWorkflows/Status.tsx @@ -0,0 +1,23 @@ +import { ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel } from "@/interfaces/approval.workflow"; +import React from "react"; +import { RiProgress5Line } from "react-icons/ri"; + +interface Props { + status: ApprovalWorkflowStatus; +} + +export default function Status({ status }: Props) { + return ( +
+
+ +
+
+

Status

+
+

{ApprovalWorkflowStatusLabel[status]}

+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/Tip.tsx b/src/components/ApprovalWorkflows/Tip.tsx new file mode 100644 index 00000000..5d0b1fbe --- /dev/null +++ b/src/components/ApprovalWorkflows/Tip.tsx @@ -0,0 +1,14 @@ +import { MdTipsAndUpdates } from "react-icons/md"; + +interface Props { + text: string; +} + +export default function Tip({ text }: Props) { + return ( +
+ +

{text}

+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/UserWithProfilePic.tsx b/src/components/ApprovalWorkflows/UserWithProfilePic.tsx new file mode 100644 index 00000000..c7c1a207 --- /dev/null +++ b/src/components/ApprovalWorkflows/UserWithProfilePic.tsx @@ -0,0 +1,24 @@ +import Image from "next/image"; + +interface Props { + prefix: string; + name: string; + profileImage: string; + textSize?: string; +} + +export default function UserWithProfilePic({ prefix, name, profileImage, textSize }: Props) { + const textClassName = `${textSize ? textSize : "text-xs"} font-medium` + return ( +
+

{prefix} {name}

+ {name} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx b/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx new file mode 100644 index 00000000..9936842f --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx @@ -0,0 +1,136 @@ +import { EditableWorkflowStep } from "@/interfaces/approval.workflow"; +import Option from "@/interfaces/option"; +import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user"; +import Image from "next/image"; +import { useEffect, useMemo, useState } from "react"; +import { AiOutlineUserAdd } from "react-icons/ai"; +import { BsTrash } from "react-icons/bs"; +import { LuGripHorizontal } from "react-icons/lu"; +import WorkflowStepNumber from "./WorkflowStepNumber"; +import WorkflowStepSelects from "./WorkflowStepSelects"; + +interface Props extends Pick { + entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]; + onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void; + isCompleted: boolean, +} + +export default function WorkflowEditableStepComponent({ + stepNumber, + assignees = [null], + finalStep, + onDelete, + onSelectChange, + entityApprovers, + isCompleted, +}: Props) { + + const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]); + const [isAdding, setIsAdding] = useState(false); + + const approverOptions: Option[] = useMemo(() => + entityApprovers + .map((approver) => ({ + value: approver.id, + label: approver.name, + icon: () => {approver.name} + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [entityApprovers] + ); + + useEffect(() => { + if (assignees && assignees.length > 0) { + const initialSelects = assignees.map((assignee) => + typeof assignee === 'string' ? approverOptions.find(option => option.value === assignee) || null : null + ); + + setSelects((prevSelects) => { + // This is needed to avoid unnecessary re-renders which can cause warning of a child component being re-rendered while parent is in the midle of also re-rendering. + const areEqual = initialSelects.length === prevSelects.length && initialSelects.every((option, idx) => option?.value === prevSelects[idx]?.value); + + if (!areEqual) { + return initialSelects; + } + return prevSelects; + }); + } + }, [assignees, approverOptions]); + + const selectedValues = useMemo(() => + selects.filter((opt): opt is Option => !!opt).map(opt => opt.value), + [selects] + ); + + const availableApproverOptions = useMemo(() => + approverOptions.filter(opt => !selectedValues.includes(opt.value)), + [approverOptions, selectedValues] + ); + + const handleAddSelectComponent = () => { + setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender. + setSelects(prev => [...prev, null]); + }; + + useEffect(() => { + if (isAdding) { + onSelectChange(selects.length, selects.length - 1, null); + setIsAdding(false); + } + }, [selects.length, isAdding, onSelectChange]); + + const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => { + const updated = [...selects]; + updated[index] = option; + setSelects(updated); + onSelectChange(numberOfSelects, index, option); + }; + + return ( +
+
+ + + {/* Vertical Bar connecting steps */} + {!finalStep && ( +
+ )} +
+ + {stepNumber !== 1 && !finalStep && !isCompleted + ? + :
+ } + +
+ +
+ +
+ + {stepNumber !== 1 && !finalStep && ( + + )} +
+
+ + ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowForm.tsx b/src/components/ApprovalWorkflows/WorkflowForm.tsx new file mode 100644 index 00000000..c7065d7c --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowForm.tsx @@ -0,0 +1,203 @@ +import { EditableApprovalWorkflow, EditableWorkflowStep } from "@/interfaces/approval.workflow"; +import Option from "@/interfaces/option"; +import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user"; +import { AnimatePresence, Reorder, motion } from "framer-motion"; +import { FaRegCheckCircle, FaSpinner } from "react-icons/fa"; +import { IoIosAddCircleOutline } from "react-icons/io"; +import Button from "../Low/Button"; +import Tip from "./Tip"; +import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent"; + +interface Props { + workflow: EditableApprovalWorkflow; + onWorkflowChange: (workflow: EditableApprovalWorkflow) => void; + entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]; + entityAvailableFormIntakers?: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]; + isLoading: boolean; + isRedirecting?: boolean; +} + +export default function WorkflowForm({ workflow, onWorkflowChange, entityApprovers, entityAvailableFormIntakers, isLoading, isRedirecting }: Props) { + const lastStep = workflow.steps[workflow.steps.length - 1]; + + const renumberSteps = (steps: EditableWorkflowStep[]): EditableWorkflowStep[] => { + return steps.map((step, index) => ({ + ...step, + stepNumber: index + 1, + })); + }; + + const addStep = () => { + const newStep: EditableWorkflowStep = { + key: Date.now(), + stepType: "approval-by", + stepNumber: workflow.steps.length, + completed: false, + assignees: [null], + firstStep: false, + finalStep: false, + }; + + const updatedSteps = [ + ...workflow.steps.slice(0, -1), + newStep, + lastStep + ]; + onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) }); + }; + + const handleDelete = (key: number | undefined) => { + if (!key) return; + + const updatedSteps = workflow.steps.filter((step) => step.key !== key); + onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) }); + }; + + const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => { + if (!key) return; + + const updatedSteps = workflow.steps.map((step) => { + if (step.key !== key) return step; + + const assignees = step.assignees ?? []; + let newAssignees = [...assignees]; + + if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed + newAssignees[index] = selectedOption?.value; + } else if (numberOfSelects === assignees.length + 1) { // means a new select was added + newAssignees.push(selectedOption?.value || null); + } + + return { ...step, assignees: newAssignees }; + }); + onWorkflowChange({ ...workflow, steps: updatedSteps }); + }; + + const handleReorder = (newOrder: EditableWorkflowStep[]) => { + let draggableIndex = 0; + const updatedSteps = workflow.steps.map((step) => { + if (!step.firstStep && !step.finalStep && !step.completed) { + return newOrder[draggableIndex++]; + } + // Keep static steps as-is + return step; + }); + onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) }); + }; + + + return ( + <> + {workflow.entityId && workflow.name && +
+
+ + +
+ + + + {workflow.steps.map((step, index) => + step.completed || step.firstStep || step.finalStep ? ( + + handleDelete(step.key)} + onSelectChange={(numberOfSelects, idx, option) => + handleSelectChange(step.key, numberOfSelects, idx, option) + } + entityApprovers={ + step.stepNumber === 1 && entityAvailableFormIntakers + ? entityAvailableFormIntakers + : entityApprovers + } + isCompleted={step.completed} + /> + + ) : ( + // Render non-completed steps as draggable items + + handleDelete(step.key)} + onSelectChange={(numberOfSelects, idx, option) => + handleSelectChange(step.key, numberOfSelects, idx, option) + } + entityApprovers={ + step.stepNumber === 1 && entityAvailableFormIntakers + ? entityAvailableFormIntakers + : entityApprovers + } + isCompleted={step.completed} + /> + + ) + )} + + + +
+ } + + ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx b/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx new file mode 100644 index 00000000..75681040 --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx @@ -0,0 +1,101 @@ +import { getUserTypeLabel, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow"; +import WorkflowStepNumber from "./WorkflowStepNumber"; +import clsx from "clsx"; +import { RiThumbUpLine } from "react-icons/ri"; +import { FaWpforms } from "react-icons/fa6"; +import { User } from "@/interfaces/user"; +import UserWithProfilePic from "./UserWithProfilePic"; + +interface Props extends WorkflowStep { + workflowAssignees: User[], + currentStep: boolean, +} + +export default function WorkflowStepComponent({ + workflowAssignees, + currentStep, + stepType, + stepNumber, + completed, + rejected = false, + completedBy, + assignees, + finalStep, + selected = false, + onClick, +}: Props) { + + const completedByUser = workflowAssignees.find((assignee) => assignee.id === completedBy); + const assigneesUsers = workflowAssignees.filter(user => assignees.includes(user.id)); + + return ( +
+
+ + + {/* Vertical Bar connecting steps */} + {!finalStep && ( +
+ )} +
+ +
+ {stepType === "approval-by" ? ( + + ) : ( + + ) + } +
+ +
+ {completed && completedBy && rejected ? ( +
+

{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`}

+ +
+ ) : completed && completedBy && !rejected ? ( +
+

{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`}

+ +
+ ) : !completed && currentStep ? ( +
+

{stepType === "approval-by" ? `Approval:` : `Form Intake:`}

+ In Progress... Assignees: +
+ {assigneesUsers.map(user => ( + + + + ))} +
+
+ ) : ( +
+

{stepType === "approval-by" ? `Approval:` : `Form Intake:`}

+ Waiting for previous steps... +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowStepNumber.tsx b/src/components/ApprovalWorkflows/WorkflowStepNumber.tsx new file mode 100644 index 00000000..842ecc6c --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowStepNumber.tsx @@ -0,0 +1,31 @@ +import { WorkflowStep } from "@/interfaces/approval.workflow"; +import clsx from "clsx"; +import { IoCheckmarkDoneSharp, IoCheckmarkSharp } from "react-icons/io5"; +import { RxCross2 } from "react-icons/rx"; + +type Props = Pick + +export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) { + return ( +
+ {rejected ? ( + + ) : completed && finalStep ? ( + + ) : completed && !finalStep ? ( + + ) : ( + {stepNumber} + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowStepSelects.tsx b/src/components/ApprovalWorkflows/WorkflowStepSelects.tsx new file mode 100644 index 00000000..878b650c --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowStepSelects.tsx @@ -0,0 +1,51 @@ +import Option from "@/interfaces/option"; +import Select from "../Low/Select"; + +interface Props { + approvers: Option[]; + selects: (Option | null | undefined)[]; + placeholder: string; + onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void; + isCompleted: boolean; +} + +export default function WorkflowStepSelects({ + approvers, + selects, + placeholder, + onSelectChange, + isCompleted, +}: Props) { + + return ( +
+ {selects.map((option, index) => { + let classes = "px-2 rounded-none"; + if (index === 0 && selects.length === 1) { + classes += " rounded-l-2xl rounded-r-2xl"; + } else if (index === 0) { + classes += " rounded-l-2xl"; + } else if (index === selects.length - 1) { + classes += " rounded-r-2xl"; + } + + return ( +
+ { const router = useRouter(); @@ -137,7 +138,7 @@ const ListeningSettings: React.FC = () => { category: s.settings.category }; }), - isDiagnostic: false, + isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. minTimer, module: "listening", id: title, @@ -149,7 +150,12 @@ const ListeningSettings: React.FC = () => { const result = await axios.post('/api/exam/listening', exam); playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); + // Successfully submitted exam + if (result.status === 200) { + toast.success(result.data.message); + } else if (result.status === 207) { + toast.warning(result.data.message); + } } else { toast.error('No audio sections found in the exam! Please either import them or generate them.'); diff --git a/src/components/ExamEditor/SettingsEditor/reading/components.tsx b/src/components/ExamEditor/SettingsEditor/reading/components.tsx index bef3bc56..6553d83e 100644 --- a/src/components/ExamEditor/SettingsEditor/reading/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/reading/components.tsx @@ -5,103 +5,140 @@ import ExercisePicker from "../../ExercisePicker"; import { generate } from "../Shared/Generate"; import GenerateBtn from "../Shared/GenerateBtn"; import { LevelPart, ReadingPart } from "@/interfaces/exam"; -import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types"; +import { + LevelSectionSettings, + ReadingSectionSettings, +} from "@/stores/examEditor/types"; import useExamEditorStore from "@/stores/examEditor"; interface Props { - localSettings: ReadingSectionSettings | LevelSectionSettings; - updateLocalAndScheduleGlobal: (updates: Partial, schedule?: boolean) => void; - currentSection: ReadingPart | LevelPart; - generatePassageDisabled?: boolean; - levelId?: number; - level?: boolean; + localSettings: ReadingSectionSettings | LevelSectionSettings; + updateLocalAndScheduleGlobal: ( + updates: Partial, + schedule?: boolean + ) => void; + currentSection: ReadingPart | LevelPart; + generatePassageDisabled?: boolean; + levelId?: number; + level?: boolean; } -const ReadingComponents: React.FC = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => { - const { currentModule } = useExamEditorStore(); - const { - focusedSection, - difficulty, - } = useExamEditorStore(state => state.modules[currentModule]); +const ReadingComponents: React.FC = ({ + localSettings, + updateLocalAndScheduleGlobal, + currentSection, + levelId, + level = false, + generatePassageDisabled = false, +}) => { + const { currentModule } = useExamEditorStore(); + const { focusedSection, difficulty } = useExamEditorStore( + (state) => state.modules[currentModule] + ); - const generatePassage = useCallback(() => { - generate( - levelId ? levelId : focusedSection, - "reading", - "passage", - { - method: 'GET', - queryParams: { - difficulty, - ...(localSettings.readingTopic && { topic: localSettings.readingTopic }) - } - }, - (data: any) => [{ - title: data.title, - text: data.text - }], - level ? focusedSection : undefined, - level - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings.readingTopic, difficulty, focusedSection, levelId]); - - const onTopicChange = useCallback((readingTopic: string) => { - updateLocalAndScheduleGlobal({ readingTopic }); - }, [updateLocalAndScheduleGlobal]); - - return ( - <> - updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)} - contentWrapperClassName={level ? `border border-ielts-reading`: ''} - disabled={generatePassageDisabled} - > -
-
- - -
-
- -
-
-
- updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })} - contentWrapperClassName={level ? `border border-ielts-reading`: ''} - disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""} - > - - - + const generatePassage = useCallback(() => { + generate( + levelId ? levelId : focusedSection, + "reading", + "passage", + { + method: "GET", + queryParams: { + difficulty, + ...(localSettings.readingTopic && { + topic: localSettings.readingTopic, + }), + }, + }, + (data: any) => [ + { + title: data.title, + text: data.text, + }, + ], + level ? focusedSection : undefined, + level ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localSettings.readingTopic, difficulty, focusedSection, levelId]); + + const onTopicChange = useCallback( + (readingTopic: string) => { + updateLocalAndScheduleGlobal({ readingTopic }); + }, + [updateLocalAndScheduleGlobal] + ); + + return ( + <> + + updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false) + } + contentWrapperClassName={level ? `border border-ielts-reading` : ""} + disabled={generatePassageDisabled} + > +
+
+ + +
+
+ +
+
+
+ + updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen }) + } + contentWrapperClassName={level ? `border border-ielts-reading` : ""} + disabled={ + currentSection === undefined || + currentSection.text === undefined || + currentSection.text.content === "" || + currentSection.text.title === "" + } + > + + + + ); }; export default ReadingComponents; diff --git a/src/components/ExamEditor/SettingsEditor/reading/index.tsx b/src/components/ExamEditor/SettingsEditor/reading/index.tsx index 590a246d..45b00931 100644 --- a/src/components/ExamEditor/SettingsEditor/reading/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/reading/index.tsx @@ -12,6 +12,7 @@ import axios from "axios"; import { playSound } from "@/utils/sound"; import { toast } from "react-toastify"; import ReadingComponents from "./components"; +import { getExamById } from "@/utils/exams"; const ReadingSettings: React.FC = () => { const router = useRouter(); @@ -46,15 +47,15 @@ const ReadingSettings: React.FC = () => { { label: "Preset: Reading Passage 1", value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas." - }, - { - label: "Preset: Reading Passage 2", + }, + { + label: "Preset: Reading Passage 2", value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views." - }, - { + }, + { label: "Preset: Reading Passage 3", value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas." - } + } ]; const canPreviewOrSubmit = sections.some( @@ -75,7 +76,7 @@ const ReadingSettings: React.FC = () => { category: localSettings.category }; }), - isDiagnostic: false, + isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. minTimer, module: "reading", id: title, @@ -88,7 +89,12 @@ const ReadingSettings: React.FC = () => { axios.post(`/api/exam/reading`, exam) .then((result) => { playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); + // Successfully submitted exam + if (result.status === 200) { + toast.success(result.data.message); + } else if (result.status === 207) { + toast.warning(result.data.message); + } }) .catch((error) => { console.log(error); diff --git a/src/components/ExamEditor/SettingsEditor/speaking/index.tsx b/src/components/ExamEditor/SettingsEditor/speaking/index.tsx index 8fccbc97..513b30b1 100644 --- a/src/components/ExamEditor/SettingsEditor/speaking/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/speaking/index.tsx @@ -11,6 +11,7 @@ import openDetachedTab from "@/utils/popout"; import axios from "axios"; import { playSound } from "@/utils/sound"; import SpeakingComponents from "./components"; +import { getExamById } from "@/utils/exams"; export interface Avatar { name: string; @@ -180,7 +181,7 @@ const SpeakingSettings: React.FC = () => { minTimer, module: "speaking", id: title, - isDiagnostic: false, + isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. variant: undefined, difficulty, instructorGender: "varied", @@ -189,7 +190,12 @@ const SpeakingSettings: React.FC = () => { const result = await axios.post('/api/exam/speaking', exam); playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); + // Successfully submitted exam + if (result.status === 200) { + toast.success(result.data.message); + } else if (result.status === 207) { + toast.warning(result.data.message); + } Array.from(urlMap.values()).forEach(url => { URL.revokeObjectURL(url); diff --git a/src/components/ExamEditor/SettingsEditor/writing/index.tsx b/src/components/ExamEditor/SettingsEditor/writing/index.tsx index 6ed9aebe..911d1817 100644 --- a/src/components/ExamEditor/SettingsEditor/writing/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/index.tsx @@ -12,6 +12,8 @@ import axios from "axios"; import { playSound } from "@/utils/sound"; import { toast } from "react-toastify"; import WritingComponents from "./components"; +import { getExamById } from "@/utils/exams"; +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; const WritingSettings: React.FC = () => { const router = useRouter(); @@ -129,7 +131,7 @@ const WritingSettings: React.FC = () => { minTimer, module: "writing", id: title, - isDiagnostic: false, + isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. variant: undefined, difficulty, private: isPrivate, @@ -138,7 +140,12 @@ const WritingSettings: React.FC = () => { const result = await axios.post(`/api/exam/writing`, exam) playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); + // Successfully submitted exam + if (result.status === 200) { + toast.success(result.data.message); + } else if (result.status === 207) { + toast.warning(result.data.message); + } } catch (error: any) { console.error('Error submitting exam:', error); diff --git a/src/components/ExamEditor/Shared/ExerciseLabel.tsx b/src/components/ExamEditor/Shared/ExerciseLabel.tsx index 024d0b59..fab965c3 100644 --- a/src/components/ExamEditor/Shared/ExerciseLabel.tsx +++ b/src/components/ExamEditor/Shared/ExerciseLabel.tsx @@ -19,8 +19,8 @@ const label = (type: string, firstId: string, lastId: string) => { const ExerciseLabel: React.FC = ({type, firstId, lastId, prompt}) => { return (
- {label(type, firstId, lastId)} -
{previewLabel(prompt)}
+ {label(type, firstId, lastId)} +
{previewLabel(prompt)}
); } diff --git a/src/components/ExamEditor/index.tsx b/src/components/ExamEditor/index.tsx index 54e6b87d..599f804b 100644 --- a/src/components/ExamEditor/index.tsx +++ b/src/components/ExamEditor/index.tsx @@ -3,12 +3,12 @@ import SectionRenderer from "./SectionRenderer"; import Checkbox from "../Low/Checkbox"; import Input from "../Low/Input"; import Select from "../Low/Select"; -import { capitalize } from "lodash"; -import { Difficulty } from "@/interfaces/exam"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "react-toastify"; -import { ModuleState, SectionState } from "@/stores/examEditor/types"; -import { Module } from "@/interfaces"; +import {capitalize} from "lodash"; +import {Difficulty} from "@/interfaces/exam"; +import {useCallback, useEffect, useMemo, useState} from "react"; +import {toast} from "react-toastify"; +import {ModuleState, SectionState} from "@/stores/examEditor/types"; +import {Module} from "@/interfaces"; import useExamEditorStore from "@/stores/examEditor"; import WritingSettings from "./SettingsEditor/writing"; import ReadingSettings from "./SettingsEditor/reading"; @@ -16,224 +16,243 @@ import LevelSettings from "./SettingsEditor/level"; import ListeningSettings from "./SettingsEditor/listening"; import SpeakingSettings from "./SettingsEditor/speaking"; import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch"; -import { defaultSectionSettings } from "@/stores/examEditor/defaults"; +import {defaultSectionSettings} from "@/stores/examEditor/defaults"; import Button from "../Low/Button"; import ResetModule from "./Standalone/ResetModule"; import ListeningInstructions from "./Standalone/ListeningInstructions"; +import {EntityWithRoles} from "@/interfaces/entity"; const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; -const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => { - const { currentModule, dispatch } = useExamEditorStore(); - const { - sections, - minTimer, - expandedSections, - examLabel, - isPrivate, - difficulty, - sectionLabels, - importModule - } = useExamEditorStore(state => state.modules[currentModule]); +const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: EntityWithRoles[]}> = ({ + levelParts = 0, + entitiesAllowEditPrivacy = [], +}) => { + const {currentModule, dispatch} = useExamEditorStore(); + const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore( + (state) => state.modules[currentModule], + ); - const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1); - const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); + const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1); + const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); - // For exam edits - useEffect(() => { - if (levelParts !== 0) { - setNumberOfLevelParts(levelParts); - dispatch({ - type: 'UPDATE_MODULE', - payload: { - updates: { - sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({ - id: i + 1, - label: `Part ${i + 1}` - })) - }, - module: "level" - } - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [levelParts]) + // For exam edits + useEffect(() => { + if (levelParts !== 0) { + setNumberOfLevelParts(levelParts); + dispatch({ + type: "UPDATE_MODULE", + payload: { + updates: { + sectionLabels: Array.from({length: levelParts}).map((_, i) => ({ + id: i + 1, + label: `Part ${i + 1}`, + })), + }, + module: "level", + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelParts]); - useEffect(() => { - const currentSections = sections; - const currentLabels = sectionLabels; - let updatedSections: SectionState[]; - let updatedLabels: any; - if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) { - const newSections = [...currentSections]; - const newLabels = [...currentLabels]; - for (let i = currentLabels.length; i < numberOfLevelParts; i++) { - if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1)); - newLabels.push({ - id: i + 1, - label: `Part ${i + 1}` - }); - } - updatedSections = newSections; - updatedLabels = newLabels; - } else if (numberOfLevelParts < currentSections.length) { - updatedSections = currentSections.slice(0, numberOfLevelParts); - updatedLabels = currentLabels.slice(0, numberOfLevelParts); - } else { - return; - } + useEffect(() => { + const currentSections = sections; + const currentLabels = sectionLabels; + let updatedSections: SectionState[]; + let updatedLabels: any; + if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) { + const newSections = [...currentSections]; + const newLabels = [...currentLabels]; + for (let i = currentLabels.length; i < numberOfLevelParts; i++) { + if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1)); + newLabels.push({ + id: i + 1, + label: `Part ${i + 1}`, + }); + } + updatedSections = newSections; + updatedLabels = newLabels; + } else if (numberOfLevelParts < currentSections.length) { + updatedSections = currentSections.slice(0, numberOfLevelParts); + updatedLabels = currentLabels.slice(0, numberOfLevelParts); + } else { + return; + } - const updatedExpandedSections = expandedSections.filter( - sectionId => updatedSections.some(section => section.sectionId === sectionId) - ); + const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId)); - dispatch({ - type: 'UPDATE_MODULE', - payload: { - updates: { - sections: updatedSections, - sectionLabels: updatedLabels, - expandedSections: updatedExpandedSections - } - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [numberOfLevelParts]); + dispatch({ + type: "UPDATE_MODULE", + payload: { + updates: { + sections: updatedSections, + sectionLabels: updatedLabels, + expandedSections: updatedExpandedSections, + }, + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [numberOfLevelParts]); - const sectionIds = sections.map((section) => section.sectionId) + const sectionIds = sections.map((section) => section.sectionId); - const updateModule = useCallback((updates: Partial) => { - dispatch({ type: 'UPDATE_MODULE', payload: { updates } }); - }, [dispatch]); + const updateModule = useCallback( + (updates: Partial) => { + dispatch({type: "UPDATE_MODULE", payload: {updates}}); + }, + [dispatch], + ); - const toggleSection = (sectionId: number) => { - if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { - toast.error("Include at least one section!"); - return; - } - dispatch({ type: 'TOGGLE_SECTION', payload: { sectionId } }); - }; + const toggleSection = (sectionId: number) => { + if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { + toast.error("Include at least one section!"); + return; + } + dispatch({type: "TOGGLE_SECTION", payload: {sectionId}}); + }; - const ModuleSettings: Record = { - reading: ReadingSettings, - writing: WritingSettings, - speaking: SpeakingSettings, - listening: ListeningSettings, - level: LevelSettings - }; + const ModuleSettings: Record = { + reading: ReadingSettings, + writing: WritingSettings, + speaking: SpeakingSettings, + listening: ListeningSettings, + level: LevelSettings, + }; - const Settings = ModuleSettings[currentModule]; - const showImport = importModule && ["reading", "listening", "level"].includes(currentModule); + const Settings = ModuleSettings[currentModule]; + const showImport = importModule && ["reading", "listening", "level"].includes(currentModule); - const updateLevelParts = (parts: number) => { - setNumberOfLevelParts(parts); - } + const updateLevelParts = (parts: number) => { + setNumberOfLevelParts(parts); + }; - return ( - <> - {showImport ? : ( - <> - {isResetModuleOpen && } -
-
- - updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })} - value={minTimer} - className="max-w-[300px]" - /> -
-
- - setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} /> -
- )} -
-
- updateModule({ isPrivate: checked })}> - Privacy (Only available for Assignments) - -
-
-
-
- - updateModule({ examLabel: text })} - roundness="xl" - value={examLabel} - required - /> -
- {currentModule === "listening" && } - -
-
- -
- -
-
- - )} - - ); + return ( + <> + {showImport ? ( + + ) : ( + <> + {isResetModuleOpen && ( + + )} +
+
+
+ + + updateModule({ + minTimer: parseInt(e) < 15 ? 15 : parseInt(e), + }) + } + value={minTimer} + className="max-w-[300px]" + /> +
+
+ + setNumberOfLevelParts(parseInt(v))} + value={numberOfLevelParts} + /> +
+ )} +
+
+ updateModule({isPrivate: checked})} + disabled={entitiesAllowEditPrivacy.length === 0}> + Privacy (Only available for Assignments) + +
+
+
+
+ + updateModule({examLabel: text})} + roundness="xl" + value={examLabel} + required + /> +
+ {currentModule === "listening" && } + +
+
+ +
+ +
+
+ + )} + + ); }; export default ExamEditor; diff --git a/src/components/Exercises/MatchSentences/DragNDrop.tsx b/src/components/Exercises/MatchSentences/DragNDrop.tsx index 237e3d46..5adbc252 100644 --- a/src/components/Exercises/MatchSentences/DragNDrop.tsx +++ b/src/components/Exercises/MatchSentences/DragNDrop.tsx @@ -24,7 +24,7 @@ const DroppableQuestionArea: React.FC = ({ question,
+ className={clsx("w-48 h-10 border-2 border-mti-purple-light self-center rounded-xl flex items-center justify-center", isOver && "border-mti-purple-dark")}> {answer && `Paragraph ${answer}`}
diff --git a/src/components/High/AssignmentCard.tsx b/src/components/High/AssignmentCard.tsx index e53bb846..0c23ed03 100644 --- a/src/components/High/AssignmentCard.tsx +++ b/src/components/High/AssignmentCard.tsx @@ -1,7 +1,10 @@ import { Session } from "@/hooks/useSessions"; import { Assignment } from "@/interfaces/results"; import { User } from "@/interfaces/user"; -import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments"; +import { + activeAssignmentFilter, + futureAssignmentFilter, +} from "@/utils/assignments"; import { sortByModuleName } from "@/utils/moduleUtils"; import clsx from "clsx"; import moment from "moment"; @@ -11,102 +14,124 @@ import Button from "../Low/Button"; import ModuleBadge from "../ModuleBadge"; interface Props { - assignment: Assignment - user: User - session?: Session - startAssignment: (assignment: Assignment) => void - resumeAssignment: (session: Session) => void + assignment: Assignment; + user: User; + session?: Session; + startAssignment: (assignment: Assignment) => void; + resumeAssignment: (session: Session) => void; } -export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) { - const router = useRouter() +export default function AssignmentCard({ + user, + assignment, + session, + startAssignment, + resumeAssignment, +}: Props) { + const hasBeenSubmitted = useMemo( + () => assignment.results.map((r) => r.user).includes(user.id), + [assignment.results, user.id] + ); - const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id]) - - return ( -
r.user).includes(user.id) && "border-mti-green-light", - )} - key={assignment.id}> -
-

{assignment.name}

- - {moment(assignment.startDate).format("DD/MM/YY, HH:mm")} - - - {moment(assignment.endDate).format("DD/MM/YY, HH:mm")} - -
-
-
- {assignment.exams - .filter((e) => e.assignee === user.id) - .map((e) => e.module) - .sort(sortByModuleName) - .map((module) => ( - - ))} -
- {futureAssignmentFilter(assignment) && !hasBeenSubmitted && ( - - )} - {activeAssignmentFilter(assignment) && !hasBeenSubmitted && ( - <> -
- -
- {!session && ( -
- -
- )} - {!!session && ( -
- -
- )} - - )} - {hasBeenSubmitted && ( - - )} -
-
- ) + return ( +
r.user).includes(user.id) && + "border-mti-green-light" + )} + key={assignment.id} + > +
+

+ {assignment.name} +

+ + {moment(assignment.startDate).format("DD/MM/YY, HH:mm")} + - + {moment(assignment.endDate).format("DD/MM/YY, HH:mm")} + +
+
+
+ {assignment.exams + .filter((e) => e.assignee === user.id) + .map((e) => e.module) + .sort(sortByModuleName) + .map((module) => ( + + ))} +
+ {futureAssignmentFilter(assignment) && !hasBeenSubmitted && ( + + )} + {activeAssignmentFilter(assignment) && !hasBeenSubmitted && ( + <> +
+ +
+ {!session && ( +
+ +
+ )} + {!!session && ( +
+ +
+ )} + + )} + {hasBeenSubmitted && ( + + )} +
+
+ ); } diff --git a/src/components/High/CardList.tsx b/src/components/High/CardList.tsx index 31075dcd..24befffb 100644 --- a/src/components/High/CardList.tsx +++ b/src/components/High/CardList.tsx @@ -2,8 +2,6 @@ import {useListSearch} from "@/hooks/useListSearch"; import usePagination from "@/hooks/usePagination"; import { clsx } from "clsx"; import {ReactNode} from "react"; -import Checkbox from "../Low/Checkbox"; -import Separator from "../Low/Separator"; interface Props { list: T[]; diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index 502384e1..b8765a97 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,4 +1,3 @@ -import useEntities from "@/hooks/useEntities"; import { EntityWithRoles } from "@/interfaces/entity"; import { User } from "@/interfaces/user"; import clsx from "clsx"; @@ -6,66 +5,126 @@ import { useRouter } from "next/router"; import { ToastContainer } from "react-toastify"; import Navbar from "../Navbar"; import Sidebar from "../Sidebar"; +import React, { useEffect, useState } from "react"; + +export const LayoutContext = React.createContext({ + onFocusLayerMouseEnter: () => {}, + setOnFocusLayerMouseEnter: (() => {}) as React.Dispatch< + React.SetStateAction<() => void> + >, + navDisabled: false, + setNavDisabled: (() => {}) as React.Dispatch>, + focusMode: false, + setFocusMode: (() => {}) as React.Dispatch>, + hideSidebar: false, + setHideSidebar: (() => {}) as React.Dispatch>, + bgColor: "bg-white", + setBgColor: (() => {}) as React.Dispatch>, + className: "", + setClassName: (() => {}) as React.Dispatch>, +}); interface Props { - user: User; - entities?: EntityWithRoles[] - children: React.ReactNode; - className?: string; - navDisabled?: boolean; - focusMode?: boolean; - hideSidebar?: boolean - bgColor?: string; - onFocusLayerMouseEnter?: () => void; + user: User; + entities?: EntityWithRoles[]; + children: React.ReactNode; + refreshPage?: boolean; } export default function Layout({ - user, - children, - className, - bgColor = "bg-white", - hideSidebar, - navDisabled = false, - focusMode = false, - onFocusLayerMouseEnter + user, + entities, + children, + refreshPage, }: Props) { - const router = useRouter(); - const { entities } = useEntities() + const [onFocusLayerMouseEnter, setOnFocusLayerMouseEnter] = useState( + () => () => {} + ); + const [navDisabled, setNavDisabled] = useState(false); + const [focusMode, setFocusMode] = useState(false); + const [hideSidebar, setHideSidebar] = useState(false); + const [bgColor, setBgColor] = useState("bg-white"); + const [className, setClassName] = useState(""); - return ( -
- - {!hideSidebar && user && ( - - )} -
- {!hideSidebar && user && ( - - )} -
- {children} -
-
-
- ); + useEffect(() => { + if (refreshPage) { + setClassName(""); + setBgColor("bg-white"); + setFocusMode(false); + setHideSidebar(false); + setNavDisabled(false); + setOnFocusLayerMouseEnter(() => () => {}); + } + }, [refreshPage]); + + const LayoutContextValue = React.useMemo( + () => ({ + onFocusLayerMouseEnter, + setOnFocusLayerMouseEnter, + navDisabled, + setNavDisabled, + focusMode, + setFocusMode, + hideSidebar, + setHideSidebar, + bgColor, + setBgColor, + className, + setClassName, + }), + [ + bgColor, + className, + focusMode, + hideSidebar, + navDisabled, + onFocusLayerMouseEnter, + ] + ); + + const router = useRouter(); + + return ( + +
+ + {!hideSidebar && user && ( + + )} +
+ {!hideSidebar && user && ( + + )} +
+ {children} +
+
+
+
+ ); } diff --git a/src/components/High/Table.tsx b/src/components/High/Table.tsx index 479e614c..2d588ffc 100644 --- a/src/components/High/Table.tsx +++ b/src/components/High/Table.tsx @@ -1,109 +1,159 @@ -import { useListSearch } from "@/hooks/useListSearch" -import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table" -import clsx from "clsx" -import { useEffect, useState } from "react" -import { BsArrowDown, BsArrowUp } from "react-icons/bs" -import Button from "../Low/Button" +import { useListSearch } from "@/hooks/useListSearch"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + useReactTable, +} from "@tanstack/react-table"; +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { BsArrowDown, BsArrowUp } from "react-icons/bs"; +import Button from "../Low/Button"; interface Props { - data: T[] - columns: ColumnDef[] - searchFields: string[][] - size?: number - onDownload?: (rows: T[]) => void - isDownloadLoading?: boolean - searchPlaceholder?: string + data: T[]; + columns: ColumnDef[]; + searchFields: string[][]; + size?: number; + onDownload?: (rows: T[]) => void; + isDownloadLoading?: boolean; + searchPlaceholder?: string; + isLoading?: boolean; } -export default function Table({ data, columns, searchFields, size = 16, onDownload, isDownloadLoading, searchPlaceholder }: Props) { - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: size, - }) +export default function Table({ + data, + columns, + searchFields, + size = 16, + onDownload, + isDownloadLoading, + searchPlaceholder, + isLoading, +}: Props) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: size, + }); - const { rows, renderSearch } = useListSearch(searchFields, data, searchPlaceholder); + const { rows, renderSearch } = useListSearch( + searchFields, + data, + searchPlaceholder + ); - const table = useReactTable({ - data: rows, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onPaginationChange: setPagination, - state: { - pagination - } - }); + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + state: { + pagination, + }, + }); - return ( -
-
- {renderSearch()} - {onDownload && ( - - ) - } -
+ return ( +
+
+ {renderSearch()} + {onDownload && ( + + )} +
-
-
- -
-
- -
Page
- - {table.getState().pagination.pageIndex + 1} of{' '} - {table.getPageCount().toLocaleString()} - -
| Total: {table.getRowCount().toLocaleString()}
-
- -
-
+
+
+ +
+
+ +
Page
+ + {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount().toLocaleString()} + +
| Total: {table.getRowCount().toLocaleString()}
+
+ +
+
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
-
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {{ - asc: , - desc: , - }[header.column.getIsSorted() as string] ?? null} -
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- ) + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {isLoading && ( +
+ +
+ )} +
+ ); } diff --git a/src/components/Low/AsyncSelect.tsx b/src/components/Low/AsyncSelect.tsx new file mode 100644 index 00000000..a839bc23 --- /dev/null +++ b/src/components/Low/AsyncSelect.tsx @@ -0,0 +1,126 @@ +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { GroupBase, StylesConfig } from "react-select"; +import ReactSelect from "react-select"; +import Option from "@/interfaces/option"; + +interface Props { + defaultValue?: Option | Option[]; + options: Option[]; + value?: Option | Option[] | null; + isLoading?: boolean; + loadOptions: (inputValue: string) => void; + onMenuScrollToBottom: (event: WheelEvent | TouchEvent) => void; + disabled?: boolean; + placeholder?: string; + isClearable?: boolean; + styles?: StylesConfig>; + className?: string; + label?: string; + flat?: boolean; +} + +interface MultiProps { + isMulti: true; + onChange: (value: Option[] | null) => void; +} + +interface SingleProps { + isMulti?: false; + onChange: (value: Option | null) => void; +} + +export default function AsyncSelect({ + value, + isMulti, + defaultValue, + options, + loadOptions, + onMenuScrollToBottom, + placeholder, + disabled, + onChange, + styles, + isClearable, + isLoading, + label, + className, + flat, +}: Props & (MultiProps | SingleProps)) { + const [target, setTarget] = useState(); + const [inputValue, setInputValue] = useState(""); + + //Implemented a debounce to prevent the API from being called too frequently + useEffect(() => { + const timer = setTimeout(() => { + loadOptions(inputValue); + }, 200); + return () => clearTimeout(timer); + }, [inputValue, loadOptions]); + + useEffect(() => { + if (document) setTarget(document.body); + }, []); + + return ( +
+ {label && ( + + )} + "Loading..."} + onInputChange={(inputValue) => { + setInputValue(inputValue); + }} + options={options} + value={value} + onChange={onChange as any} + placeholder={placeholder} + menuPortalTarget={target} + defaultValue={defaultValue} + onMenuScrollToBottom={onMenuScrollToBottom} + styles={ + styles || { + menuPortal: (base) => ({ ...base, zIndex: 9999 }), + control: (styles) => ({ + ...styles, + paddingLeft: "4px", + border: "none", + outline: "none", + ":focus": { + outline: "none", + }, + }), + option: (styles, state) => ({ + ...styles, + backgroundColor: state.isFocused + ? "#D5D9F0" + : state.isSelected + ? "#7872BF" + : "white", + color: state.isFocused ? "black" : styles.color, + }), + } + } + isDisabled={disabled} + isClearable={isClearable} + /> +
+ ); +} diff --git a/src/components/Medium/RecordFilter.tsx b/src/components/Medium/RecordFilter.tsx index d3cbdc31..3650a331 100644 --- a/src/components/Medium/RecordFilter.tsx +++ b/src/components/Medium/RecordFilter.tsx @@ -9,165 +9,216 @@ import useRecordStore from "@/stores/recordStore"; import { EntityWithRoles } from "@/interfaces/entity"; import { mapBy } from "@/utils"; import { useAllowedEntities } from "@/hooks/useEntityPermissions"; - +import useUsersSelect from "../../hooks/useUsersSelect"; +import AsyncSelect from "../Low/AsyncSelect"; type TimeFilter = "months" | "weeks" | "days"; type Filter = TimeFilter | "assignments" | undefined; interface Props { - user: User; - entities: EntityWithRoles[] - users: User[] - filterState: { - filter: Filter, - setFilter: React.Dispatch> - }, - assignments?: boolean; - children?: ReactNode + user: User; + entities: EntityWithRoles[]; + isAdmin?: boolean; + filterState: { + filter: Filter; + setFilter: React.Dispatch>; + }; + assignments?: boolean; + children?: ReactNode; } const defaultSelectableCorporate = { - value: "", - label: "All", + value: "", + label: "All", }; const RecordFilter: React.FC = ({ - user, - entities, - users, - filterState, - assignments = true, - children + user, + entities, + filterState, + assignments = true, + isAdmin = false, + children, }) => { - const { filter, setFilter } = filterState; + const { filter, setFilter } = filterState; - const [entity, setEntity] = useState() + const [entity, setEntity] = useState(); - const [, setStatsUserId] = useRecordStore((state) => [ - state.selectedUser, - state.setSelectedUser - ]); + const [, setStatsUserId] = useRecordStore((state) => [ + state.selectedUser, + state.setSelectedUser, + ]); - const allowedViewEntities = useAllowedEntities(user, entities, 'view_student_record') + const entitiesToSearch = useMemo(() => { + if(entity) return entity + if (isAdmin) return undefined; + return mapBy(entities, "id"); + }, [entities, entity, isAdmin]); - const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity]) + const { users, isLoading, onScrollLoadMoreOptions, loadOptions } = + useUsersSelect({ + size: 50, + orderBy: "name", + direction: "asc", + entities: entitiesToSearch, + }); - useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]) + const allowedViewEntities = useAllowedEntities( + user, + entities, + "view_student_record" + ); + - const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { - setFilter((prev) => (prev === value ? undefined : value)); - }; + useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]); - return ( -
-
- {checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && ( - <> -
- + const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { + setFilter((prev) => (prev === value ? undefined : value)); + }; - ({ - value: x.id, - label: `${x.name} - ${x.email}`, - }))} - defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} - onChange={(value) => setStatsUserId(value?.value!)} - styles={{ - menuPortal: (base) => ({ ...base, zIndex: 9999 }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} - /> -
- - )} - {(user.type === "corporate" || user.type === "teacher") && !children && ( -
- + ({ - value: x.id, - label: `${x.name} - ${x.email}`, - }))} - defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} - onChange={(value) => setStatsUserId(value?.value!)} - styles={{ - menuPortal: (base) => ({ ...base, zIndex: 9999 }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} - /> -
- )} - {children} -
-
- {assignments && ( - - )} - - - -
-
- ); -} + setStatsUserId(value?.value!)} + styles={{ + menuPortal: (base) => ({ ...base, zIndex: 9999 }), + option: (styles, state) => ({ + ...styles, + backgroundColor: state.isFocused + ? "#D5D9F0" + : state.isSelected + ? "#7872BF" + : "white", + color: state.isFocused ? "black" : styles.color, + }), + }} + /> +
+ + )} + {(user.type === "corporate" || user.type === "teacher") && + !children && ( +
+ + + setStatsUserId(value?.value!)} + styles={{ + menuPortal: (base) => ({ ...base, zIndex: 9999 }), + option: (styles, state) => ({ + ...styles, + backgroundColor: state.isFocused + ? "#D5D9F0" + : state.isSelected + ? "#7872BF" + : "white", + color: state.isFocused ? "black" : styles.color, + }), + }} + /> +
+ )} + {children} +
+
+ {assignments && ( + + )} + + + +
+
+ ); +}; export default RecordFilter; diff --git a/src/components/Medium/UserProfileSkeleton.tsx b/src/components/Medium/UserProfileSkeleton.tsx new file mode 100644 index 00000000..33b46f3b --- /dev/null +++ b/src/components/Medium/UserProfileSkeleton.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +export default function UserProfileSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index daffc144..99027edb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,18 +2,18 @@ import clsx from "clsx"; import { IconType } from "react-icons"; import { MdSpaceDashboard } from "react-icons/md"; import { - BsFileEarmarkText, - BsClockHistory, - BsPencil, - BsGraphUp, - BsChevronBarRight, - BsChevronBarLeft, - BsShieldFill, - BsCloudFill, - BsCurrencyDollar, - BsClipboardData, - BsPeople, + BsFileEarmarkText, + BsClockHistory, + BsGraphUp, + BsChevronBarRight, + BsChevronBarLeft, + BsShieldFill, + BsCloudFill, + BsCurrencyDollar, + BsClipboardData, + BsPeople, } from "react-icons/bs"; +import { GoWorkflow } from "react-icons/go"; import { CiDumbbell } from "react-icons/ci"; import { RiLogoutBoxFill } from "react-icons/ri"; import Link from "next/link"; @@ -24,218 +24,478 @@ import { preventNavigation } from "@/utils/navigation.disabled"; import usePreferencesStore from "@/stores/preferencesStore"; import { User } from "@/interfaces/user"; import useTicketsListener from "@/hooks/useTicketsListener"; -import { checkAccess, getTypesOfUser } from "@/utils/permissions"; +import { getTypesOfUser } from "@/utils/permissions"; import usePermissions from "@/hooks/usePermissions"; import { EntityWithRoles } from "@/interfaces/entity"; -import { useAllowedEntities, useAllowedEntitiesSomePermissions } from "@/hooks/useEntityPermissions"; +import { + useAllowedEntities, + useAllowedEntitiesSomePermissions, +} from "@/hooks/useEntityPermissions"; import { useMemo } from "react"; +import { PermissionType } from "../interfaces/permissions"; interface Props { - path: string; - navDisabled?: boolean; - focusMode?: boolean; - onFocusLayerMouseEnter?: () => void; - className?: string; - user: User; - entities?: EntityWithRoles[] + path: string; + navDisabled?: boolean; + focusMode?: boolean; + onFocusLayerMouseEnter?: () => void; + className?: string; + user: User; + entities?: EntityWithRoles[]; } interface NavProps { - Icon: IconType; - label: string; - path: string; - keyPath: string; - disabled?: boolean; - isMinimized?: boolean; - badge?: number; + Icon: IconType; + label: string; + path: string; + keyPath: string; + disabled?: boolean; + isMinimized?: boolean; + badge?: number; } -const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => { - return ( - - - {!isMinimized && {label}} - {!!badge && badge > 0 && ( -
- {badge} -
- )} - - ); +const Nav = ({ + Icon, + label, + path, + keyPath, + disabled = false, + isMinimized = false, + badge, +}: NavProps) => { + return ( + + + {!isMinimized && {label}} + {!!badge && badge > 0 && ( +
+ {badge} +
+ )} + + ); }; export default function Sidebar({ - path, - entities = [], - navDisabled = false, - focusMode = false, - user, - onFocusLayerMouseEnter, - className + path, + entities = [], + navDisabled = false, + focusMode = false, + user, + onFocusLayerMouseEnter, + className, }: Props) { - const router = useRouter(); + const router = useRouter(); - const isAdmin = useMemo(() => ['developer', 'admin'].includes(user?.type), [user?.type]) + const isAdmin = useMemo( + () => ["developer", "admin"].includes(user?.type), + [user?.type] + ); - const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); + const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [ + state.isSidebarMinimized, + state.toggleSidebarMinimized, + ]); - const { totalAssignedTickets } = useTicketsListener(user.id); - const { permissions } = usePermissions(user.id); + const { permissions } = usePermissions(user.id); - const entitiesAllowStatistics = useAllowedEntities(user, entities, "view_statistics") - const entitiesAllowPaymentRecord = useAllowedEntities(user, entities, "view_payment_record") + const entitiesAllowStatistics = useAllowedEntities( + user, + entities, + "view_statistics" + ); + const entitiesAllowPaymentRecord = useAllowedEntities( + user, + entities, + "view_payment_record" + ); - const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [ - "generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level" - ]) + const entitiesAllowGeneration = useAllowedEntitiesSomePermissions( + user, + entities, + [ + "generate_reading", + "generate_listening", + "generate_writing", + "generate_speaking", + "generate_level", + ] + ); - const logout = async () => { - axios.post("/api/logout").finally(() => { - setTimeout(() => router.reload(), 500); - }); - }; + const sidebarPermissions = useMemo<{ [key: string]: boolean }>(() => { + if (user.type === "developer") { + return { + viewExams: true, + viewStats: true, + viewRecords: true, + viewTickets: true, + viewClassrooms: true, + viewSettings: true, + viewPaymentRecord: true, + viewGeneration: true, + viewApprovalWorkflows: true, + }; + } + const sidebarPermissions: { [key: string]: boolean } = { + viewExams: false, + viewStats: false, + viewRecords: false, + viewTickets: false, + viewClassrooms: false, + viewSettings: false, + viewPaymentRecord: false, + viewGeneration: false, + viewApprovalWorkflows: false, + }; - const disableNavigation = preventNavigation(navDisabled, focusMode); + if (!user || !user?.type) return sidebarPermissions; - return ( -
-
-
-
-
+ const neededPermissions = permissions.reduce((acc, curr) => { + if ( + ["viewExams", "viewRecords", "viewTickets"].includes(curr as string) + ) { + acc.push(curr); + } + return acc; + }, [] as PermissionType[]); -
-
- {isMinimized ? : } - {!isMinimized && Minimize} -
-
{ } : logout} - className={clsx( - "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", - isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", - )}> - - {!isMinimized && Log Out} -
-
- {focusMode && } -
- ); + if ( + ["student", "teacher", "developer"].includes(user.type) && + neededPermissions.includes("viewExams") + ) { + sidebarPermissions["viewExams"] = true; + } + if ( + getTypesOfUser(["agent"]).includes(user.type) && + (entitiesAllowStatistics.length > 0 || + neededPermissions.includes("viewStats")) + ) { + sidebarPermissions["viewStats"] = true; + } + if ( + [ + "admin", + "developer", + "teacher", + "corporate", + "mastercorporate", + ].includes(user.type) && + (entitiesAllowGeneration.length > 0 || isAdmin) + ) { + sidebarPermissions["viewGeneration"] = true; + sidebarPermissions["viewApprovalWorkflows"] = true; + } + if ( + getTypesOfUser(["agent"]).includes(user.type) && + neededPermissions.includes("viewRecords") + ) { + sidebarPermissions["viewRecords"] = true; + } + if ( + ["admin", "developer", "agent"].includes(user.type) && + neededPermissions.includes("viewTickets") + ) { + sidebarPermissions["viewTickets"] = true; + } + if ( + [ + "admin", + "mastercorporate", + "developer", + "corporate", + "teacher", + "student", + ].includes(user.type) + ) { + sidebarPermissions["viewClassrooms"] = true; + } + if (getTypesOfUser(["student", "agent"]).includes(user.type)) { + sidebarPermissions["viewSettings"] = true; + } + if ( + ["admin", "developer", "agent", "corporate", "mastercorporate"].includes( + user.type + ) && + entitiesAllowPaymentRecord.length > 0 + ) { + sidebarPermissions["viewPaymentRecord"] = true; + } + return sidebarPermissions; + }, [ + entitiesAllowGeneration.length, + entitiesAllowPaymentRecord.length, + entitiesAllowStatistics.length, + isAdmin, + permissions, + user, + ]); + + const { totalAssignedTickets } = useTicketsListener( + user.id, + sidebarPermissions["viewTickets"] + ); + + const logout = async () => { + axios.post("/api/logout").finally(() => { + setTimeout(() => router.reload(), 500); + }); + }; + + const disableNavigation = preventNavigation(navDisabled, focusMode); + + return ( +
+
+
+
+
+ +
+
+ {isMinimized ? ( + + ) : ( + + )} + {!isMinimized && ( + Minimize + )} +
+
{} : logout} + className={clsx( + "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", + isMinimized ? "w-fit" : "w-full min-w-[250px] px-8" + )} + > + + {!isMinimized && ( + Log Out + )} +
+
+ {focusMode && ( + + )} +
+ ); } diff --git a/src/components/Solutions/MatchSentences.tsx b/src/components/Solutions/MatchSentences.tsx index 3f177a46..47fd2792 100644 --- a/src/components/Solutions/MatchSentences.tsx +++ b/src/components/Solutions/MatchSentences.tsx @@ -29,7 +29,7 @@ function QuestionSolutionArea({
void; + user: User; + page: "exercises" | "exams"; + onStart: ( + modules: Module[], + avoidRepeated: boolean, + variant: Variant + ) => void; } -export default function Selection({user, page, onStart}: Props) { - const [selectedModules, setSelectedModules] = useState([]); - const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); - const [variant, setVariant] = useState("full"); +export default function Selection({ user, page, onStart }: Props) { + const [selectedModules, setSelectedModules] = useState([]); + const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); + const [variant, setVariant] = useState("full"); - const {data: stats} = useFilterRecordsByUser(user?.id); - const {sessions, isLoading, reload} = useSessions(user.id); + const { + data: { + allStats = [], + moduleCount: { reading, listening, writing, speaking, level } = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }, + }, + } = useStats<{ + allStats: Stat[]; + moduleCount: Record; + }>(user?.id, !user?.id, "byModule"); + const { sessions, isLoading, reload } = useSessions(user.id); - const dispatch = useExamStore((state) => state.dispatch); + const dispatch = useExamStore((state) => state.dispatch); - const toggleModule = (module: Module) => { - const modules = selectedModules.filter((x) => x !== module); - setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); - }; + const toggleModule = (module: Module) => { + const modules = selectedModules.filter((x) => x !== module); + setSelectedModules((prev) => + prev.includes(module) ? modules : [...modules, module] + ); + }; - const isCompleteExam = useMemo(() => - ["reading", "listening", "writing", "speaking"].every(m => selectedModules.includes(m as Module)), [selectedModules] - ) + const isCompleteExam = useMemo( + () => + ["reading", "listening", "writing", "speaking"].every((m) => + selectedModules.includes(m as Module) + ), + [selectedModules] + ); - const loadSession = async (session: Session) => { - dispatch({type: "SET_SESSION", payload: { session }}) - }; + const loadSession = async (session: Session) => { + dispatch({ type: "SET_SESSION", payload: { session } }); + }; - return ( - <> -
- {user && ( - , - label: "Reading", - value: totalExamsByModule(stats, "reading"), - tooltip: "The amount of reading exams performed.", - }, - { - icon: , - label: "Listening", - value: totalExamsByModule(stats, "listening"), - tooltip: "The amount of listening exams performed.", - }, - { - icon: , - label: "Writing", - value: totalExamsByModule(stats, "writing"), - tooltip: "The amount of writing exams performed.", - }, - { - icon: , - label: "Speaking", - value: totalExamsByModule(stats, "speaking"), - tooltip: "The amount of speaking exams performed.", - }, - { - icon: , - label: "Level", - value: totalExamsByModule(stats, "level"), - tooltip: "The amount of level exams performed.", - }, - ]} - /> - )} + return ( + <> +
+ {user && ( + + ), + label: "Reading", + value: reading || 0, + tooltip: "The amount of reading exams performed.", + }, + { + icon: ( + + ), + label: "Listening", + value: listening || 0, + tooltip: "The amount of listening exams performed.", + }, + { + icon: ( + + ), + label: "Writing", + value: writing || 0, + tooltip: "The amount of writing exams performed.", + }, + { + icon: ( + + ), + label: "Speaking", + value: speaking || 0, + tooltip: "The amount of speaking exams performed.", + }, + { + icon: ( + + ), + label: "Level", + value: level || 0, + tooltip: "The amount of level exams performed.", + }, + ]} + /> + )} -
- About {capitalize(page)} - - {page === "exercises" && ( - <> - In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full - potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar - drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully - designed to make learning English both enjoyable and effective. Whether you're looking to reinforce specific - skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence. - Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language - acquisition. Your linguistic adventure starts here! - - )} - {page === "exams" && ( - <> - Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and - enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate - your abilities. Whether you're a beginner or a seasoned learner, our exams cater to all levels, providing a - comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of - self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a - destination; it's a testament to your dedication and our commitment to empowering you with the English language. - - )} - -
+
+ About {capitalize(page)} + + {page === "exercises" && ( + <> + In the realm of language acquisition, practice makes perfect, + and our exercises are the key to unlocking your full potential. + Dive into a world of interactive and engaging exercises that + cater to diverse learning styles. From grammar drills that build + a strong foundation to vocabulary challenges that broaden your + lexicon, our exercises are carefully designed to make learning + English both enjoyable and effective. Whether you're + looking to reinforce specific skills or embark on a holistic + language journey, our exercises are your companions in the + pursuit of excellence. Embrace the joy of learning as you + navigate through a variety of activities that cater to every + facet of language acquisition. Your linguistic adventure starts + here! + + )} + {page === "exams" && ( + <> + Welcome to the heart of success on your English language + journey! Our exams are crafted with precision to assess and + enhance your language skills. Each test is a passport to your + linguistic prowess, designed to challenge and elevate your + abilities. Whether you're a beginner or a seasoned learner, + our exams cater to all levels, providing a comprehensive + evaluation of your reading, writing, speaking, and listening + skills. Prepare to embark on a journey of self-discovery and + language mastery as you navigate through our thoughtfully + curated exams. Your success is not just a destination; it's + a testament to your dedication and our commitment to empowering + you with the English language. + + )} + +
- {sessions.length > 0 && ( -
-
-
- Unfinished Sessions - -
-
- - {sessions - .sort((a, b) => moment(b.date).diff(moment(a.date))) - .map((session) => ( - - ))} - -
- )} + {sessions.length > 0 && ( +
+
+
+ + Unfinished Sessions + + +
+
+ + {sessions.map((session) => ( + + ))} + +
+ )} -
-
toggleModule("reading") : undefined} - className={clsx( - "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", - )}> -
- -
- Reading: -

- Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English. -

- {!selectedModules.includes("reading") && !selectedModules.includes("level") && ( -
- )} - {(selectedModules.includes("reading")) && ( - - )} - {selectedModules.includes("level") && } -
-
toggleModule("listening") : undefined} - className={clsx( - "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", - )}> -
- -
- Listening: -

- Improve your ability to follow conversations in English and your ability to understand different accents and intonations. -

- {!selectedModules.includes("listening") && !selectedModules.includes("level") && ( -
- )} - {(selectedModules.includes("listening")) && ( - - )} - {selectedModules.includes("level") && } -
-
toggleModule("writing") : undefined} - className={clsx( - "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", - )}> -
- -
- Writing: -

- Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays. -

- {!selectedModules.includes("writing") && !selectedModules.includes("level") && ( -
- )} - {(selectedModules.includes("writing")) && ( - - )} - {selectedModules.includes("level") && } -
-
toggleModule("speaking") : undefined} - className={clsx( - "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", - )}> -
- -
- Speaking: -

- You'll have access to interactive dialogs, pronunciation exercises and speech recordings. -

- {!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( -
- )} - {(selectedModules.includes("speaking")) && ( - - )} - {selectedModules.includes("level") && } -
-
toggleModule("level") : undefined} - className={clsx( - "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", - )}> -
- -
- Level: -

You'll be able to test your english level with multiple choice questions.

- {!selectedModules.includes("level") && selectedModules.length === 0 && ( -
- )} - {(selectedModules.includes("level")) && ( - - )} - {!selectedModules.includes("level") && selectedModules.length > 0 && ( - - )} -
-
-
-
-
setAvoidRepeatedExams((prev) => !prev)}> - -
- -
- - Avoid Repeated Questions - -
-
setVariant((prev) => (prev === "full" ? "partial" : "full"))}> - -
- -
- Full length exams -
-
-
- -
-
- - -
-
-
- - ); +
+
toggleModule("reading") + : undefined + } + className={clsx( + "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", + selectedModules.includes("reading") + ? "border-mti-purple-light" + : "border-mti-gray-platinum" + )} + > +
+ +
+ Reading: +

+ Expand your vocabulary, improve your reading comprehension and + improve your ability to interpret texts in English. +

+ {!selectedModules.includes("reading") && + !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("reading") && ( + + )} + {selectedModules.includes("level") && ( + + )} +
+
toggleModule("listening") + : undefined + } + className={clsx( + "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", + selectedModules.includes("listening") + ? "border-mti-purple-light" + : "border-mti-gray-platinum" + )} + > +
+ +
+ Listening: +

+ Improve your ability to follow conversations in English and your + ability to understand different accents and intonations. +

+ {!selectedModules.includes("listening") && + !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("listening") && ( + + )} + {selectedModules.includes("level") && ( + + )} +
+
toggleModule("writing") + : undefined + } + className={clsx( + "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", + selectedModules.includes("writing") + ? "border-mti-purple-light" + : "border-mti-gray-platinum" + )} + > +
+ +
+ Writing: +

+ Allow you to practice writing in a variety of formats, from simple + paragraphs to complex essays. +

+ {!selectedModules.includes("writing") && + !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("writing") && ( + + )} + {selectedModules.includes("level") && ( + + )} +
+
toggleModule("speaking") + : undefined + } + className={clsx( + "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", + selectedModules.includes("speaking") + ? "border-mti-purple-light" + : "border-mti-gray-platinum" + )} + > +
+ +
+ Speaking: +

+ You'll have access to interactive dialogs, pronunciation + exercises and speech recordings. +

+ {!selectedModules.includes("speaking") && + !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("speaking") && ( + + )} + {selectedModules.includes("level") && ( + + )} +
+
toggleModule("level") + : undefined + } + className={clsx( + "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", + selectedModules.includes("level") + ? "border-mti-purple-light" + : "border-mti-gray-platinum" + )} + > +
+ +
+ Level: +

+ You'll be able to test your english level with multiple + choice questions. +

+ {!selectedModules.includes("level") && + selectedModules.length === 0 && ( +
+ )} + {selectedModules.includes("level") && ( + + )} + {!selectedModules.includes("level") && + selectedModules.length > 0 && ( + + )} +
+
+
+
+
setAvoidRepeatedExams((prev) => !prev)} + > + +
+ +
+ + Avoid Repeated Questions + +
+
+ setVariant((prev) => (prev === "full" ? "partial" : "full")) + } + > + +
+ +
+ Full length exams +
+
+
+ +
+
+ + +
+
+
+ + ); } diff --git a/src/hooks/useApprovalWorkflow.tsx b/src/hooks/useApprovalWorkflow.tsx new file mode 100644 index 00000000..9908cfbe --- /dev/null +++ b/src/hooks/useApprovalWorkflow.tsx @@ -0,0 +1,24 @@ +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; +import axios from "axios"; +import { useCallback, useEffect, useState } from "react"; + +export default function useApprovalWorkflow(id: string) { + const [workflow, setWorkflow] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = useCallback(() => { + setIsLoading(true); + axios + .get(`/api/approval-workflows/${id}`) + .then((response) => setWorkflow(response.data)) + .catch((error) => { + setIsError(true); + }) + .finally(() => setIsLoading(false)); + }, []); + + useEffect(getData, [getData]); + + return { workflow, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useApprovalWorkflows.tsx b/src/hooks/useApprovalWorkflows.tsx new file mode 100644 index 00000000..1b950c2f --- /dev/null +++ b/src/hooks/useApprovalWorkflows.tsx @@ -0,0 +1,24 @@ +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; +import axios from "axios"; +import { useCallback, useEffect, useState } from "react"; + +export default function useApprovalWorkflows() { + const [workflows, setWorkflows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = useCallback(() => { + setIsLoading(true); + axios + .get(`/api/approval-workflows`) + .then((response) => setWorkflows(response.data)) + .catch((error) => { + setIsError(true); + }) + .finally(() => setIsLoading(false)); + }, []); + + useEffect(getData, [getData]); + + return { workflows, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useEntities.tsx b/src/hooks/useEntities.tsx index 434d5d1f..a9f17094 100644 --- a/src/hooks/useEntities.tsx +++ b/src/hooks/useEntities.tsx @@ -1,23 +1,22 @@ import { EntityWithRoles } from "@/interfaces/entity"; -import { Discount } from "@/interfaces/paypal"; -import { Code, Group, User } from "@/interfaces/user"; import axios from "axios"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; -export default function useEntities() { +export default function useEntities(shouldNot?: boolean) { const [entities, setEntities] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); - const getData = () => { + const getData = useCallback(() => { + if (shouldNot) return; setIsLoading(true); axios .get("/api/entities?showRoles=true") .then((response) => setEntities(response.data)) .finally(() => setIsLoading(false)); - }; + }, [shouldNot]); - useEffect(getData, []); + useEffect(getData, [getData]) return { entities, isLoading, isError, reload: getData }; } diff --git a/src/hooks/useEntitiesUsers.tsx b/src/hooks/useEntitiesUsers.tsx index f1c7be6a..17385409 100644 --- a/src/hooks/useEntitiesUsers.tsx +++ b/src/hooks/useEntitiesUsers.tsx @@ -1,6 +1,5 @@ -import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity"; -import { Discount } from "@/interfaces/paypal"; -import { Code, Group, Type, User } from "@/interfaces/user"; +import { WithLabeledEntities } from "@/interfaces/entity"; +import { Type, User } from "@/interfaces/user"; import axios from "axios"; import { useEffect, useState } from "react"; @@ -12,7 +11,9 @@ export default function useEntitiesUsers(type?: Type) { const getData = () => { setIsLoading(true); axios - .get[]>(`/api/entities/users${type ? "?type=" + type : ""}`) + .get[]>( + `/api/entities/users${type ? "?type=" + type : ""}` + ) .then((response) => setUsers(response.data)) .finally(() => setIsLoading(false)); }; diff --git a/src/hooks/useEvaluationPolling.tsx b/src/hooks/useEvaluationPolling.tsx index fe54da8f..603c2b17 100644 --- a/src/hooks/useEvaluationPolling.tsx +++ b/src/hooks/useEvaluationPolling.tsx @@ -1,95 +1,114 @@ -import { UserSolution } from '@/interfaces/exam'; -import useExamStore from '@/stores/exam'; -import { StateFlags } from '@/stores/exam/types'; -import axios from 'axios'; -import { SetStateAction, useEffect, useRef } from 'react'; +import { UserSolution } from "@/interfaces/exam"; +import useExamStore from "@/stores/exam"; +import axios from "axios"; +import { useEffect, useRef } from "react"; +import { useRouter } from "next/router"; -type UseEvaluationPolling = (props: { - pendingExercises: string[], - setPendingExercises: React.Dispatch>, -}) => void; +const useEvaluationPolling = (sessionIds: string[], mode: "exam" | "records", userId: string) => { + const { setUserSolutions, userSolutions } = useExamStore(); + const pollingTimeoutsRef = useRef>(new Map()); + const router = useRouter(); -const useEvaluationPolling: UseEvaluationPolling = ({ - pendingExercises, - setPendingExercises, -}) => { - const { - flags, sessionId, user, - userSolutions, evaluated, - setEvaluated, setFlags - } = useExamStore(); + const poll = async (sessionId: string) => { + try { + const { data: statusData } = await axios.get('/api/evaluate/status', { + params: { op: 'pending', userId, sessionId } + }); - const pollingTimeoutRef = useRef(); + if (!statusData.hasPendingEvaluation) { - useEffect(() => { - return () => { - if (pollingTimeoutRef.current) { - clearTimeout(pollingTimeoutRef.current); - } - }; - }, []); + let solutionsOrStats = userSolutions; - useEffect(() => { - if (!flags.pendingEvaluation || pendingExercises.length === 0) { - - if (pollingTimeoutRef.current) { - clearTimeout(pollingTimeoutRef.current); - } - return; + if (mode === "records") { + const res = await axios.get(`/api/stats/session/${sessionId}`) + solutionsOrStats = res.data; } + const { data: completedSolutions } = await axios.post('/api/evaluate/fetchSolutions?op=session', { + sessionId, + userId, + stats: solutionsOrStats, + }); - const pollStatus = async () => { - try { - const { data } = await axios.get('/api/evaluate/status', { - params: { - sessionId, - userId: user, - exerciseIds: pendingExercises.join(',') - } - }); + await axios.post('/api/stats/disabled', { + sessionId, + userId, + solutions: completedSolutions, + }); - if (data.finishedExerciseIds.length > 0) { - const remainingExercises = pendingExercises.filter( - id => !data.finishedExerciseIds.includes(id) - ); + const timeout = pollingTimeoutsRef.current.get(sessionId); + if (timeout) clearTimeout(timeout); + pollingTimeoutsRef.current.delete(sessionId); - setPendingExercises(remainingExercises); + if (mode === "exam") { + const updatedSolutions = userSolutions.map(solution => { + const completed = completedSolutions.find( + (c: UserSolution) => c.exercise === solution.exercise + ); + return completed || solution; + }); - if (remainingExercises.length === 0) { - const evaluatedData = await axios.post('/api/evaluate/fetchSolutions', { - sessionId, - userId: user, - userSolutions - }); + setUserSolutions(updatedSolutions); + } else { + router.reload(); + } + } else { + if (pollingTimeoutsRef.current.has(sessionId)) { + clearTimeout(pollingTimeoutsRef.current.get(sessionId)); + } + pollingTimeoutsRef.current.set( + sessionId, + setTimeout(() => poll(sessionId), 5000) + ); + } + } catch (error) { + if (pollingTimeoutsRef.current.has(sessionId)) { + clearTimeout(pollingTimeoutsRef.current.get(sessionId)); + } + pollingTimeoutsRef.current.set( + sessionId, + setTimeout(() => poll(sessionId), 5000) + ); + } + }; - const newEvaluations = evaluatedData.data.filter( - (newEval: UserSolution) => - !evaluated.some(existingEval => existingEval.exercise === newEval.exercise) - ); + useEffect(() => { + if (mode === "exam") { + const hasDisabledSolutions = userSolutions.some(s => s.isDisabled); - setEvaluated([...evaluated, ...newEvaluations]); - setFlags({ pendingEvaluation: false }); - return; - } - } + if (hasDisabledSolutions && sessionIds.length > 0) { + poll(sessionIds[0]); + } else { + pollingTimeoutsRef.current.forEach((timeout) => { + clearTimeout(timeout); + }); + pollingTimeoutsRef.current.clear(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, sessionIds, userSolutions]); - if (pendingExercises.length > 0) { - pollingTimeoutRef.current = setTimeout(pollStatus, 5000); - } - } catch (error) { - console.error('Evaluation polling error:', error); - pollingTimeoutRef.current = setTimeout(pollStatus, 5000); - } - }; + useEffect(() => { + if (mode === "records" && sessionIds.length > 0) { + sessionIds.forEach(sessionId => { + poll(sessionId); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, sessionIds]); - pollStatus(); + useEffect(() => { + const timeouts = pollingTimeoutsRef.current; + return () => { + timeouts.forEach((timeout) => { + clearTimeout(timeout); + }); + timeouts.clear(); + }; + }, []); - return () => { - if (pollingTimeoutRef.current) { - clearTimeout(pollingTimeoutRef.current); - } - }; - }); + return { + isPolling: pollingTimeoutsRef.current.size > 0 + }; }; export default useEvaluationPolling; diff --git a/src/hooks/useFilterRecordsByUser.tsx b/src/hooks/useFilterRecordsByUser.tsx index 908dab17..5add150b 100644 --- a/src/hooks/useFilterRecordsByUser.tsx +++ b/src/hooks/useFilterRecordsByUser.tsx @@ -3,13 +3,13 @@ import { useEffect, useState } from "react"; const endpoints: Record = { stats: "/api/stats", - training: "/api/training" + training: "/api/training", }; export default function useFilterRecordsByUser( id?: string, shouldNotQuery?: boolean, - recordType: string = 'stats' + recordType: string = "stats" ) { type ElementType = T extends (infer U)[] ? U : never; @@ -19,7 +19,7 @@ export default function useFilterRecordsByUser( const endpointURL = endpoints[recordType] || endpoints.stats; // CAUTION: This makes the assumption that the record enpoint has a /user/${id} endpoint - const endpoint = !id ? endpointURL: `${endpointURL}/user/${id}`; + const endpoint = !id ? endpointURL : `${endpointURL}/user/${id}`; const getData = () => { if (shouldNotQuery) return; @@ -31,7 +31,7 @@ export default function useFilterRecordsByUser( .get(endpoint) .then((response) => { // CAUTION: This makes the assumption ElementType has a "user" field that contains the user id - setData(response.data.filter((x: ElementType) => (id ? (x as any).user === id : true)) as T); + setData(response.data); }) .catch(() => setIsError(true)) .finally(() => setIsLoading(false)); @@ -42,10 +42,10 @@ export default function useFilterRecordsByUser( // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, shouldNotQuery, recordType, endpoint]); - return { - data, - reload: getData, - isLoading, - isError + return { + data, + reload: getData, + isLoading, + isError, }; -} \ No newline at end of file +} diff --git a/src/hooks/usePermissions.tsx b/src/hooks/usePermissions.tsx index f760de65..d3769fa3 100644 --- a/src/hooks/usePermissions.tsx +++ b/src/hooks/usePermissions.tsx @@ -17,8 +17,7 @@ export default function usePermissions(user: string) { .get(`/api/permissions`) .then((response) => { const permissionTypes = response.data - .filter((x) => !x.users.includes(user)) - .reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]); + .reduce((acc, curr) => curr.users.includes(user)? acc : [...acc, curr.type], [] as PermissionType[]); setPermissions(permissionTypes); }) .finally(() => setIsLoading(false)); diff --git a/src/hooks/useStats.tsx b/src/hooks/useStats.tsx new file mode 100644 index 00000000..1f182469 --- /dev/null +++ b/src/hooks/useStats.tsx @@ -0,0 +1,42 @@ +import axios from "axios"; +import { useCallback, useEffect, useState } from "react"; + +export default function useStats( + id?: string, + shouldNotQuery: boolean = !id, + queryType: string = "stats" +) { + type ElementType = T extends (infer U)[] ? U : never; + + const [data, setData] = useState({} as unknown as T); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = useCallback(() => { + if (shouldNotQuery) return; + + setIsLoading(true); + setIsError(false); + let endpoint = `/api/stats/user/${id}`; + if (queryType) endpoint += `?query=${queryType}`; + axios + .get(endpoint) + .then((response) => { + console.log(response.data); + setData(response.data); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [id, shouldNotQuery, queryType]); + + useEffect(() => { + getData(); + }, [getData]); + + return { + data, + reload: getData, + isLoading, + isError, + }; +} diff --git a/src/hooks/useTicketsListener.tsx b/src/hooks/useTicketsListener.tsx index 520ee947..75a552e9 100644 --- a/src/hooks/useTicketsListener.tsx +++ b/src/hooks/useTicketsListener.tsx @@ -1,22 +1,30 @@ -import React from "react"; -import useTickets from "./useTickets"; +import { useState, useEffect, useCallback } from "react"; +import axios from "axios"; -const useTicketsListener = (userId?: string) => { - const { tickets, reload } = useTickets(); +const useTicketsListener = (userId?: string, canFetch?: boolean) => { + const [assignedTickets, setAssignedTickets] = useState([]); - React.useEffect(() => { + const getData = useCallback(() => { + axios + .get("/api/tickets/assignedToUser") + .then((response) => setAssignedTickets(response.data)); + }, []); + + useEffect(() => { + if (!canFetch) return; + getData(); + }, [canFetch, getData]); + + useEffect(() => { + if (!canFetch) return; const intervalId = setInterval(() => { - reload(); + getData(); }, 60 * 1000); return () => clearInterval(intervalId); - }, [reload]); + }, [assignedTickets, canFetch, getData]); if (userId) { - const assignedTickets = tickets.filter( - (ticket) => ticket.assignedTo === userId && ticket.status === "submitted" - ); - return { assignedTickets, totalAssignedTickets: assignedTickets.length, diff --git a/src/hooks/useUserData.tsx b/src/hooks/useUserData.tsx new file mode 100644 index 00000000..d43f1d0a --- /dev/null +++ b/src/hooks/useUserData.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState, useCallback } from "react"; +import { User } from "../interfaces/user"; +import axios from "axios"; + +export default function useUserData({ + userId, +}: { + userId: string; +}) { + const [userData, setUserData] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = useCallback(() => { + if (!userId ) return; + setIsLoading(true); + axios + .get(`/api/users/${userId}`) + .then((response) => setUserData(response.data)) + .finally(() => setIsLoading(false)) + .catch((error) => setIsError(true)); + }, [userId]); + + useEffect(getData, [getData]); + + return { userData, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useUsersSelect.tsx b/src/hooks/useUsersSelect.tsx new file mode 100644 index 00000000..42fbc02b --- /dev/null +++ b/src/hooks/useUsersSelect.tsx @@ -0,0 +1,99 @@ +import Axios from "axios"; +import { useCallback, useEffect, useState } from "react"; +import { setupCache } from "axios-cache-interceptor"; +import Option from "../interfaces/option"; +const instance = Axios.create(); +const axios = setupCache(instance); + +export default function useUsersSelect(props?: { + type?: string; + size?: number; + orderBy?: string; + direction?: "asc" | "desc"; + entities?: string[] | string; +}) { + const [inputValue, setInputValue] = useState(""); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const onScrollLoadMoreOptions = useCallback(() => { + if (users.length === total) return; + const params = new URLSearchParams(); + + if (!!props) + Object.keys(props).forEach((key) => { + if (props[key as keyof typeof props] !== undefined) + params.append(key, props[key as keyof typeof props]!.toString()); + }); + setIsLoading(true); + + return axios + .get<{ users: Option[]; total: number }>( + `/api/users/search?value=${inputValue}&page=${ + page + 1 + }&${params.toString()}`, + { headers: { page: "register" } } + ) + .then((response) => { + setPage((curr) => curr + 1); + setTotal(response.data.total); + setUsers((curr) => [...curr, ...response.data.users]); + setIsLoading(false); + return response.data.users; + }); + }, [inputValue, page, props, total, users.length]); + + const loadOptions = useCallback( + async (inputValue: string,forced?:boolean) => { + let load = true; + setInputValue((currValue) => { + if (!forced&&currValue === inputValue) { + load = false; + return currValue; + } + return inputValue; + }); + if (!load) return; + const params = new URLSearchParams(); + + if (!!props) + Object.keys(props).forEach((key) => { + if (props[key as keyof typeof props] !== undefined) + params.append(key, props[key as keyof typeof props]!.toString()); + }); + setIsLoading(true); + setPage(0); + + return axios + .get<{ users: Option[]; total: number }>( + `/api/users/search?value=${inputValue}&page=0&${params.toString()}`, + { headers: { page: "register" } } + ) + .then((response) => { + setTotal(response.data.total); + setUsers(response.data.users); + setIsLoading(false); + return response.data.users; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props?.entities, props?.type, props?.size, props?.orderBy, props?.direction] + ); + + useEffect(() => { + loadOptions("",true); + }, [loadOptions]); + + return { + users, + total, + isLoading, + isError, + onScrollLoadMoreOptions, + loadOptions, + inputValue, + }; +} diff --git a/src/interfaces/approval.workflow.ts b/src/interfaces/approval.workflow.ts new file mode 100644 index 00000000..38d4c7f5 --- /dev/null +++ b/src/interfaces/approval.workflow.ts @@ -0,0 +1,72 @@ +import { ObjectId } from "mongodb"; +import { Module } from "."; +import { Type, User, userTypeLabels, userTypeLabelsShort } from "./user"; + +export interface ApprovalWorkflow { + _id?: ObjectId, + name: string, + entityId: string, + requester: User["id"], + startDate: number, + modules: Module[], + examId?: string, + status: ApprovalWorkflowStatus, + steps: WorkflowStep[], +} + +export interface EditableApprovalWorkflow extends Omit { + id: string, + steps: EditableWorkflowStep[], +} + +export type StepType = "form-intake" | "approval-by"; +export const StepTypeLabel: Record = { + "form-intake": "Form Intake", + "approval-by": "Approval", +}; + +export interface WorkflowStep { + stepType: StepType, + stepNumber: number, + completed: boolean, + rejected?: boolean, + completedBy?: User["id"], + completedDate?: number, + assignees: (User["id"])[]; + firstStep?: boolean, + finalStep?: boolean, + selected?: boolean, + comments?: string, + examChanges?: string[], + onClick?: React.MouseEventHandler +} + +export interface EditableWorkflowStep { + key: number, + stepType: StepType, + stepNumber: number, + completed: boolean, + rejected?: boolean, + completedBy?: User["id"], + completedDate?: number, + assignees: (User["id"] | null | undefined)[]; // bit of an hack, but allowing null or undefined values allows us to match one to one the select input components with the assignees array. And since select inputs allow undefined or null values, it is allowed here too, but must validate required input before form submission + firstStep: boolean, + finalStep?: boolean, + onDelete?: () => void; +} + +export function getUserTypeLabel(type: Type | undefined): string { + if (type) return userTypeLabels[type]; + return ''; +} +export function getUserTypeLabelShort(type: Type | undefined): string { + if (type) return userTypeLabelsShort[type]; + return ''; +} + +export type ApprovalWorkflowStatus = "approved" | "pending" | "rejected"; +export const ApprovalWorkflowStatusLabel: Record = { + approved: "Approved", + pending: "Pending", + rejected: "Rejected", +}; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 79248c34..4692f2f7 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,4 +1,11 @@ export type Module = "reading" | "listening" | "writing" | "speaking" | "level"; +export const ModuleTypeLabels: Record = { + reading: "Reading", + listening: "Listening", + writing: "Writing", + speaking: "Speaking", + level: "Level", +}; export interface Step { min: number; diff --git a/src/interfaces/results.ts b/src/interfaces/results.ts index 58832a4f..ba94076f 100644 --- a/src/interfaces/results.ts +++ b/src/interfaces/results.ts @@ -37,3 +37,5 @@ export interface Assignment { } export type AssignmentWithCorporateId = Assignment & { corporateId: string }; + +export type AssignmentWithHasResults = Assignment & { hasResults: boolean }; diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index b1ed48bc..e16f7e0d 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -170,4 +170,24 @@ export interface Code { export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate"; export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; +export const userTypeLabels: Record = { + student: "Student", + teacher: "Teacher", + corporate: "Corporate", + admin: "Admin", + developer: "Developer", + agent: "Agent", + mastercorporate: "Master Corporate", +}; + +export const userTypeLabelsShort: Record = { + student: "", + teacher: "Prof.", + corporate: "Dir.", + admin: "Admin", + developer: "Dev.", + agent: "Agent", + mastercorporate: "Dir.", +}; + export type WithUser = T extends { participants: string[] } ? Omit & { participants: User[] } : T; diff --git a/src/lib/createWorkflowsOnExamCreation.ts b/src/lib/createWorkflowsOnExamCreation.ts new file mode 100644 index 00000000..732a1f18 --- /dev/null +++ b/src/lib/createWorkflowsOnExamCreation.ts @@ -0,0 +1,34 @@ +import { Module } from "@/interfaces"; +import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be"; + +export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) { + const results = await Promise.all( + examEntities.map(async (entity) => { + const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor); + if (!configuredWorkflow) { + return { entity, created: false }; + } + + configuredWorkflow.modules.push(examModule as Module); + configuredWorkflow.name = examId; + configuredWorkflow.examId = examId; + configuredWorkflow.entityId = entity; + configuredWorkflow.startDate = Date.now(); + + try { + await createApprovalWorkflow("active-workflows", configuredWorkflow); + return { entity, created: true }; + } catch (error: any) { + return { entity, created: false }; + } + }) + ); + + const successCount = results.filter((r) => r.created).length; + const totalCount = examEntities.length; + + return { + successCount, + totalCount, + }; +} diff --git a/src/pages/(admin)/CorporateGradingSystem.tsx b/src/pages/(admin)/CorporateGradingSystem.tsx index 28c006b6..9506108d 100644 --- a/src/pages/(admin)/CorporateGradingSystem.tsx +++ b/src/pages/(admin)/CorporateGradingSystem.tsx @@ -5,192 +5,329 @@ 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 { + CEFR_STEPS, + GENERAL_STEPS, + IELTS_STEPS, + TOFEL_STEPS, +} from "@/resources/grading"; import { checkAccess } from "@/utils/permissions"; import axios from "axios"; import clsx from "clsx"; -import { Divider } from "primereact/divider"; -import { useEffect, useState } from "react"; +import { + Dispatch, + memo, + SetStateAction, + useCallback, + useEffect, + useState, +} from "react"; import { BsPlusCircle, BsTrash } from "react-icons/bs"; import { toast } from "react-toastify"; const areStepsOverlapped = (steps: Step[]) => { - for (let i = 0; i < steps.length; i++) { - if (i === 0) continue; + for (let i = 0; i < steps.length; i++) { + if (i === 0) continue; - const step = steps[i]; - const previous = steps[i - 1]; + const step = steps[i]; + const previous = steps[i - 1]; - if (previous.max >= step.min) return true; - } + if (previous.max >= step.min) return true; + } - return false; + return false; }; +interface RowProps { + min: number; + max: number; + index: number; + label: string; + isLast: boolean; + isLoading: boolean; + setSteps: Dispatch>; + addRow: (index: number) => void; +} +function GradingRow({ + min, + max, + label, + index, + isLoading, + isLast, + setSteps, + addRow, +}: RowProps) { + const onChangeMin = useCallback( + (e: string) => { + setSteps((prev) => + prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x)) + ); + }, + [index, setSteps] + ); + + const onChangeMax = useCallback( + (e: string) => { + setSteps((prev) => + prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x)) + ); + }, + [index, setSteps] + ); + + const onChangeLabel = useCallback( + (e: string) => { + setSteps((prev) => + prev.map((x, i) => (i === index ? { ...x, label: e } : x)) + ); + }, + [index, setSteps] + ); + + const onAddRow = useCallback(() => addRow(index), [addRow, index]); + + const removeRow = useCallback( + () => setSteps((prev) => prev.filter((_, i) => i !== index)), + [index, setSteps] + ); + return ( + <> +
+
+ + + +
+ {index !== 0 && !isLast && ( + + )} +
+ + {!isLast && ( + + )} + + ); +} +const GradingRowMemo = memo(GradingRow); interface Props { - user: User; - entitiesGrading: Grading[]; - entities: Entity[] - mutate: () => void + user: User; + entitiesGrading: Grading[]; + entities: Entity[]; + mutate: () => void; } -export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) { - const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined) - const [isLoading, setIsLoading] = useState(false); - const [steps, setSteps] = useState([]); - const [otherEntities, setOtherEntities] = useState([]) +export default function CorporateGradingSystem({ + user, + entitiesGrading = [], + entities = [], + mutate, +}: Props) { + const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined); + const [isLoading, setIsLoading] = useState(false); + const [steps, setSteps] = useState([]); + const [otherEntities, setOtherEntities] = useState([]); - useEffect(() => { - if (entity) { - const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps - setSteps(entitySteps || []) - } - }, [entitiesGrading, entity]) + useEffect(() => { + if (entity) { + const entitySteps = entitiesGrading.find( + (e) => e.entity === entity + )!.steps; + setSteps(entitySteps || []); + } + }, [entitiesGrading, entity]); - const saveGradingSystem = () => { - 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."); + const saveGradingSystem = () => { + 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."); - setIsLoading(true); - axios - .post("/api/grading", { user: user.id, entity, 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)); - }; + setIsLoading(true); + axios + .post("/api/grading", { user: user.id, entity, 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)); + }; - 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."); + 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") + 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)); - }; + 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 ( -
- -
- - ({ value: e.id, label: e.label }))} - onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))} - isMulti - /> - - - - )} + return ( +
+ +
+ + ({ value: e.id, label: e.label }))} + onChange={(e) => + !e + ? setOtherEntities([]) + : setOtherEntities(e.map((o) => o.value!)) + } + isMulti + /> + + + + )} - {steps.map((step, index) => ( - <> -
-
- setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x)))} - name="min" - /> - setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, label: e } : x)))} - name="min" - /> - setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x)))} - name="max" - /> -
- {index !== 0 && index !== steps.length - 1 && ( - - )} -
+ +
+ + + + +
- {index < steps.length - 1 && ( - - )} - - ))} + {steps.map((step, index) => ( + + ))} - -
- ); + +
+ ); } diff --git a/src/pages/(admin)/Lists/ExamList.tsx b/src/pages/(admin)/Lists/ExamList.tsx index 5b1fb789..5e090ee7 100644 --- a/src/pages/(admin)/Lists/ExamList.tsx +++ b/src/pages/(admin)/Lists/ExamList.tsx @@ -1,33 +1,32 @@ -import { useMemo, useState } from "react"; -import { PERMISSIONS } from "@/constants/userPermissions"; +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 { Type, User } from "@/interfaces/user"; +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 {getExamById} from "@/utils/exams"; +import {countExercises} from "@/utils/moduleUtils"; +import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import axios from "axios"; -import clsx from "clsx"; -import { capitalize, uniq } from "lodash"; -import { useRouter } from "next/router"; -import { BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX } from "react-icons/bs"; -import { toast } from "react-toastify"; -import { useListSearch } from "@/hooks/useListSearch"; +import {capitalize, uniq} from "lodash"; +import {useRouter} from "next/router"; +import {BsBan, BsCheck, BsCircle, 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 } from "@/utils/permissions"; +import {checkAccess} from "@/utils/permissions"; import useGroups from "@/hooks/useGroups"; import Button from "@/components/Low/Button"; -import { EntityWithRoles } from "@/interfaces/entity"; -import { FiEdit, FiArrowRight } from 'react-icons/fi'; -import { HiArrowRight } from "react-icons/hi"; -import { BiEdit } from "react-icons/bi"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {BiEdit} from "react-icons/bi"; +import {findBy, mapBy} from "@/utils"; +import {getUserName} from "@/utils/users"; const searchFields = [["module"], ["id"], ["createdBy"]]; -const CLASSES: { [key in Module]: string } = { +const CLASSES: {[key in Module]: string} = { reading: "text-ielts-reading", listening: "text-ielts-listening", speaking: "text-ielts-speaking", @@ -37,45 +36,20 @@ const CLASSES: { [key in Module]: string } = { const columnHelper = createColumnHelper(); -const ExamOwnerSelector = ({ options, exam, onSave }: { options: User[]; exam: Exam; onSave: (owners: string[]) => void }) => { - const [owners, setOwners] = useState(exam.owners || []); - - return ( -
-
- {options.map((c) => ( - - ))} -
- -
- ); -}; - -export default function ExamList({ user, entities }: { user: User; entities: EntityWithRoles[]; }) { +export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) { const [selectedExam, setSelectedExam] = useState(); - const { exams, reload } = useExams(); - const { users } = useUsers(); - const { groups } = useGroups({ admin: user?.id, userType: user?.type }); + const {exams, reload} = useExams(); + const {users} = useUsers(); - const filteredExams = useMemo(() => exams.filter((e) => { - if (!e.private) return true - return (e.owners || []).includes(user?.id || "") - }), [exams, user?.id]) - - const filteredCorporates = useMemo(() => { - const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id); - return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate"); - }, [users, groups, user]); + const filteredExams = useMemo( + () => + exams.filter((e) => { + if (!e.private) return true; + return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent)); + }), + [exams, user?.entities], + ); const parsedExams = useMemo(() => { return filteredExams.map((exam) => { @@ -93,7 +67,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent }); }, [filteredExams, users]); - const { rows: filteredRows, renderSearch } = useListSearch(searchFields, parsedExams); + const {rows: filteredRows, renderSearch} = useListSearch(searchFields, parsedExams); const dispatch = useExamStore((state) => state.dispatch); @@ -108,7 +82,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent return; } - dispatch({ type: "INIT_EXAM", payload: { exams: [exam], modules: [module] } }) + dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}}); router.push("/exam"); }; @@ -117,7 +91,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return; axios - .patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private }) + .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) { @@ -135,29 +109,6 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent .finally(reload); }; - const updateExam = async (exam: Exam, body: object) => { - if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return; - - axios - .patch(`/api/exam/${exam.module}/${exam.id}`, body) - .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) - .finally(() => setSelectedExam(undefined)); - }; - const deleteExam = async (exam: Exam) => { if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return; @@ -222,12 +173,12 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent }), columnHelper.accessor("createdBy", { header: "Created By", - cell: (info) => info.getValue(), + cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"), }), { header: "", id: "actions", - cell: ({ row }: { row: { original: Exam } }) => { + cell: ({row}: {row: {original: Exam}}) => { return (
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && ( @@ -270,7 +221,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent const handleExamEdit = () => { router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`); - } + }; return (
@@ -286,30 +237,17 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
-

- Exam ID: {selectedExam.id} -

+

Exam ID: {selectedExam.id}

-

- Click 'Next' to proceed to the exam editor. -

+

Click 'Next' to proceed to the exam editor.

- -
diff --git a/src/pages/(admin)/Lists/UserList.tsx b/src/pages/(admin)/Lists/UserList.tsx index 63ac86e9..e11524e8 100644 --- a/src/pages/(admin)/Lists/UserList.tsx +++ b/src/pages/(admin)/Lists/UserList.tsx @@ -6,7 +6,12 @@ import clsx from "clsx"; import { capitalize } from "lodash"; import moment from "moment"; import { useEffect, useMemo, useState } from "react"; -import { BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsTrash } from "react-icons/bs"; +import { + BsCheck, + BsCheckCircle, + BsFillExclamationOctagonFill, + BsTrash, +} from "react-icons/bs"; import { toast } from "react-toastify"; import { countries, TCountries } from "countries-list"; import countryCodes from "country-codes-list"; @@ -24,426 +29,597 @@ import { WithLabeledEntities } from "@/interfaces/entity"; import Table from "@/components/High/Table"; import useEntities from "@/hooks/useEntities"; import { useAllowedEntities } from "@/hooks/useEntityPermissions"; -import { findAllowedEntities } from "@/utils/permissions"; const columnHelper = createColumnHelper>(); const searchFields = [["name"], ["email"], ["entities", ""]]; export default function UserList({ - user, - filters = [], - type, - renderHeader, + user, + filters = [], + type, + renderHeader, }: { - user: User; - filters?: ((user: User) => boolean)[]; - type?: Type; - renderHeader?: (total: number) => JSX.Element; + user: User; + filters?: ((user: User) => boolean)[]; + type?: Type; + renderHeader?: (total: number) => JSX.Element; }) { - const [showDemographicInformation, setShowDemographicInformation] = useState(false); - const [selectedUser, setSelectedUser] = useState(); + const [showDemographicInformation, setShowDemographicInformation] = + useState(false); + const [selectedUser, setSelectedUser] = useState(); - const { users, reload } = useEntitiesUsers(type) - const { entities } = useEntities() + const { users, isLoading, reload } = useEntitiesUsers(type); + const { entities } = useEntities(); - const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type]) + const isAdmin = useMemo( + () => ["admin", "developer"].includes(user?.type), + [user?.type] + ); - const entitiesViewStudents = useAllowedEntities(user, entities, "view_students") - const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students") - const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students") + const entitiesViewStudents = useAllowedEntities( + user, + entities, + "view_students" + ); + const entitiesEditStudents = useAllowedEntities( + user, + entities, + "edit_students" + ); + const entitiesDeleteStudents = useAllowedEntities( + user, + entities, + "delete_students" + ); - const entitiesViewTeachers = useAllowedEntities(user, entities, "view_teachers") - const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers") - const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers") + const entitiesViewTeachers = useAllowedEntities( + user, + entities, + "view_teachers" + ); + const entitiesEditTeachers = useAllowedEntities( + user, + entities, + "edit_teachers" + ); + const entitiesDeleteTeachers = useAllowedEntities( + user, + entities, + "delete_teachers" + ); - const entitiesViewCorporates = useAllowedEntities(user, entities, "view_corporates") - const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates") - const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates") + const entitiesViewCorporates = useAllowedEntities( + user, + entities, + "view_corporates" + ); + const entitiesEditCorporates = useAllowedEntities( + user, + entities, + "edit_corporates" + ); + const entitiesDeleteCorporates = useAllowedEntities( + user, + entities, + "delete_corporates" + ); - const entitiesViewMasterCorporates = useAllowedEntities(user, entities, "view_mastercorporates") - const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates") - const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates") + const entitiesViewMasterCorporates = useAllowedEntities( + user, + entities, + "view_mastercorporates" + ); + const entitiesEditMasterCorporates = useAllowedEntities( + user, + entities, + "edit_mastercorporates" + ); + const entitiesDeleteMasterCorporates = useAllowedEntities( + user, + entities, + "delete_mastercorporates" + ); - const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list") + const entitiesDownloadUsers = useAllowedEntities( + user, + entities, + "download_user_list" + ); - const appendUserFilters = useFilterStore((state) => state.appendUserFilter); - const router = useRouter(); + const appendUserFilters = useFilterStore((state) => state.appendUserFilter); + const router = useRouter(); - const expirationDateColor = (date: Date) => { - const momentDate = moment(date); - const today = moment(new Date()); + const expirationDateColor = (date: Date) => { + const momentDate = moment(date); + const today = moment(new Date()); - if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through"; - if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; - if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light"; - if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light"; - }; + if (today.isAfter(momentDate)) + return "!text-mti-red-light font-bold line-through"; + if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; + if (today.add(2, "weeks").isAfter(momentDate)) + return "!text-mti-rose-light"; + if (today.add(1, "months").isAfter(momentDate)) + return "!text-mti-orange-light"; + }; - const allowedUsers = useMemo(() => users.filter((u) => { - if (isAdmin) return true - if (u.id === user?.id) return false + const allowedUsers = useMemo( + () => + users.filter((u) => { + if (isAdmin) return true; + if (u.id === user?.id) return false; - switch (u.type) { - case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id)) - case "teacher": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewTeachers, 'id').includes(id)) - case 'corporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewCorporates, 'id').includes(id)) - case 'mastercorporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewMasterCorporates, 'id').includes(id)) - default: return false - } - }) - , [entitiesViewCorporates, entitiesViewMasterCorporates, entitiesViewStudents, entitiesViewTeachers, isAdmin, user?.id, users]) + switch (u.type) { + case "student": + return mapBy(u.entities || [], "id").some((id) => + mapBy(entitiesViewStudents, "id").includes(id) + ); + case "teacher": + return mapBy(u.entities || [], "id").some((id) => + mapBy(entitiesViewTeachers, "id").includes(id) + ); + case "corporate": + return mapBy(u.entities || [], "id").some((id) => + mapBy(entitiesViewCorporates, "id").includes(id) + ); + case "mastercorporate": + return mapBy(u.entities || [], "id").some((id) => + mapBy(entitiesViewMasterCorporates, "id").includes(id) + ); + default: + return false; + } + }), + [ + entitiesViewCorporates, + entitiesViewMasterCorporates, + entitiesViewStudents, + entitiesViewTeachers, + isAdmin, + user?.id, + users, + ] + ); - const displayUsers = useMemo(() => - filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers, - [filters, allowedUsers]) + const displayUsers = useMemo( + () => + filters.length > 0 + ? filters.reduce((d, f) => d.filter(f), allowedUsers) + : allowedUsers, + [filters, allowedUsers] + ); - const deleteAccount = (user: User) => { - if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return; + const deleteAccount = (user: User) => { + if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) + return; - axios - .delete<{ ok: boolean }>(`/api/user?id=${user.id}`) - .then(() => { - toast.success("User deleted successfully!"); - reload() - }) - .catch(() => { - toast.error("Something went wrong!", { toastId: "delete-error" }); - }) - .finally(reload); - }; + axios + .delete<{ ok: boolean }>(`/api/user?id=${user.id}`) + .then(() => { + toast.success("User deleted successfully!"); + reload(); + }) + .catch(() => { + toast.error("Something went wrong!", { toastId: "delete-error" }); + }) + .finally(reload); + }; - const verifyAccount = (user: User) => { - axios - .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { - ...user, - isVerified: true, - }) - .then(() => { - toast.success("User verified successfully!"); - reload(); - }) - .catch(() => { - toast.error("Something went wrong!", { toastId: "update-error" }); - }); - }; + const verifyAccount = (user: User) => { + axios + .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { + ...user, + isVerified: true, + }) + .then(() => { + toast.success("User verified successfully!"); + reload(); + }) + .catch(() => { + toast.error("Something went wrong!", { toastId: "update-error" }); + }); + }; - const toggleDisableAccount = (user: User) => { - if ( - !confirm( - `Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name - }'s account? This change is usually related to their payment state.`, - ) - ) - return; + const toggleDisableAccount = (user: User) => { + if ( + !confirm( + `Are you sure you want to ${ + user.status === "disabled" ? "enable" : "disable" + } ${ + user.name + }'s account? This change is usually related to their payment state.` + ) + ) + return; - axios - .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { - ...user, - status: user.status === "disabled" ? "active" : "disabled", - }) - .then(() => { - toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`); - reload(); - }) - .catch(() => { - toast.error("Something went wrong!", { toastId: "update-error" }); - }); - }; + axios + .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { + ...user, + status: user.status === "disabled" ? "active" : "disabled", + }) + .then(() => { + toast.success( + `User ${ + user.status === "disabled" ? "enabled" : "disabled" + } successfully!` + ); + reload(); + }) + .catch(() => { + toast.error("Something went wrong!", { toastId: "update-error" }); + }); + }; - const getEditPermission = (type: Type) => { - if (type === "student") return entitiesEditStudents - if (type === "teacher") return entitiesEditTeachers - if (type === "corporate") return entitiesEditCorporates - if (type === "mastercorporate") return entitiesEditMasterCorporates + const getEditPermission = (type: Type) => { + if (type === "student") return entitiesEditStudents; + if (type === "teacher") return entitiesEditTeachers; + if (type === "corporate") return entitiesEditCorporates; + if (type === "mastercorporate") return entitiesEditMasterCorporates; - return [] - } + return []; + }; - const getDeletePermission = (type: Type) => { - if (type === "student") return entitiesDeleteStudents - if (type === "teacher") return entitiesDeleteTeachers - if (type === "corporate") return entitiesDeleteCorporates - if (type === "mastercorporate") return entitiesDeleteMasterCorporates + const getDeletePermission = (type: Type) => { + if (type === "student") return entitiesDeleteStudents; + if (type === "teacher") return entitiesDeleteTeachers; + if (type === "corporate") return entitiesDeleteCorporates; + if (type === "mastercorporate") return entitiesDeleteMasterCorporates; - return [] - } + return []; + }; - const canEditUser = (u: User) => - isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id)) + const canEditUser = (u: User) => + isAdmin || + u.entities.some((e) => + mapBy(getEditPermission(u.type), "id").includes(e.id) + ); - const canDeleteUser = (u: User) => - isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id)) + const canDeleteUser = (u: User) => + isAdmin || + u.entities.some((e) => + mapBy(getDeletePermission(u.type), "id").includes(e.id) + ); - const actionColumn = ({ row }: { row: { original: User } }) => { - const canEdit = canEditUser(row.original) - const canDelete = canDeleteUser(row.original) + const actionColumn = ({ row }: { row: { original: User } }) => { + const canEdit = canEditUser(row.original); + const canDelete = canDeleteUser(row.original); - return ( -
- {!row.original.isVerified && canEdit && ( -
verifyAccount(row.original)}> - -
- )} - {canEdit && ( -
toggleDisableAccount(row.original)}> - {row.original.status === "disabled" ? ( - - ) : ( - - )} -
- )} - {canDelete && ( -
deleteAccount(row.original)}> - -
- )} -
- ); - }; + return ( +
+ {!row.original.isVerified && canEdit && ( +
verifyAccount(row.original)} + > + +
+ )} + {canEdit && ( +
toggleDisableAccount(row.original)} + > + {row.original.status === "disabled" ? ( + + ) : ( + + )} +
+ )} + {canDelete && ( +
deleteAccount(row.original)} + > + +
+ )} +
+ ); + }; - const demographicColumns = [ - columnHelper.accessor("name", { - header: "Name", - cell: ({ row, getValue }) => ( -
- canEditUser(row.original) ? setSelectedUser(row.original) : null - }> - {getValue()} -
- ), - }), - columnHelper.accessor("demographicInformation.country", { - header: "Country", - cell: (info) => - info.getValue() - ? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name - } (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})` - : "N/A", - }), - columnHelper.accessor("demographicInformation.phone", { - header: "Phone", - cell: (info) => info.getValue() || "N/A", - enableSorting: true, - }), - columnHelper.accessor( - (x) => - x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment, - { - id: "employment", - header: "Employment", - cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A", - enableSorting: true, - }, - ), - columnHelper.accessor("lastLogin", { - header: "Last Login", - cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"), - }), - columnHelper.accessor("demographicInformation.gender", { - header: "Gender", - cell: (info) => capitalize(info.getValue()) || "N/A", - enableSorting: true, - }), - { - header: ( - setShowDemographicInformation((prev) => !prev)}> - Switch - - ), - id: "actions", - cell: actionColumn, - sortable: false - }, - ]; + const demographicColumns = [ + columnHelper.accessor("name", { + header: "Name", + cell: ({ row, getValue }) => ( +
+ canEditUser(row.original) ? setSelectedUser(row.original) : null + } + > + {getValue()} +
+ ), + }), + columnHelper.accessor("demographicInformation.country", { + header: "Country", + cell: (info) => + info.getValue() + ? `${ + countryCodes.findOne("countryCode" as any, info.getValue())?.flag + } ${ + countries[info.getValue() as unknown as keyof TCountries]?.name + } (+${ + countryCodes.findOne("countryCode" as any, info.getValue()) + ?.countryCallingCode + })` + : "N/A", + }), + columnHelper.accessor("demographicInformation.phone", { + header: "Phone", + cell: (info) => info.getValue() || "N/A", + enableSorting: true, + }), + columnHelper.accessor( + (x) => + x.type === "corporate" || x.type === "mastercorporate" + ? x.demographicInformation?.position + : x.demographicInformation?.employment, + { + id: "employment", + header: "Employment", + cell: (info) => + (info.row.original.type === "corporate" + ? info.getValue() + : capitalize(info.getValue())) || "N/A", + enableSorting: true, + } + ), + columnHelper.accessor("lastLogin", { + header: "Last Login", + cell: (info) => + !!info.getValue() + ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") + : "N/A", + }), + columnHelper.accessor("demographicInformation.gender", { + header: "Gender", + cell: (info) => capitalize(info.getValue()) || "N/A", + enableSorting: true, + }), + { + header: ( + setShowDemographicInformation((prev) => !prev)} + > + Switch + + ), + id: "actions", + cell: actionColumn, + sortable: false, + }, + ]; - const defaultColumns = [ - columnHelper.accessor("name", { - header: "Name", - cell: ({ row, getValue }) => ( -
- canEditUser(row.original) ? setSelectedUser(row.original) : null - }> - {getValue()} -
- ), - }), - columnHelper.accessor("email", { - header: "E-mail", - cell: ({ row, getValue }) => ( -
(canEditUser(row.original) ? setSelectedUser(row.original) : null)}> - {getValue()} -
- ), - }), - columnHelper.accessor("type", { - header: "Type", - cell: (info) => USER_TYPE_LABELS[info.getValue()], - }), - columnHelper.accessor("studentID", { - header: "Student ID", - cell: (info) => info.getValue() || "N/A", - }), - columnHelper.accessor("entities", { - header: "Entities", - cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '), - }), - columnHelper.accessor("subscriptionExpirationDate", { - header: "Expiration", - cell: (info) => ( - - {!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")} - - ), - }), - columnHelper.accessor("isVerified", { - header: "Verified", - cell: (info) => ( -
-
- -
-
- ), - }), - { - header: ( - setShowDemographicInformation((prev) => !prev)}> - Switch - - ), - id: "actions", - cell: actionColumn, - sortable: false - }, - ]; + const defaultColumns = [ + columnHelper.accessor("name", { + header: "Name", + cell: ({ row, getValue }) => ( +
+ canEditUser(row.original) ? setSelectedUser(row.original) : null + } + > + {getValue()} +
+ ), + }), + columnHelper.accessor("email", { + header: "E-mail", + cell: ({ row, getValue }) => ( +
+ canEditUser(row.original) ? setSelectedUser(row.original) : null + } + > + {getValue()} +
+ ), + }), + columnHelper.accessor("type", { + header: "Type", + cell: (info) => USER_TYPE_LABELS[info.getValue()], + }), + columnHelper.accessor("studentID", { + header: "Student ID", + cell: (info) => info.getValue() || "N/A", + }), + columnHelper.accessor("entities", { + header: "Entities", + cell: ({ getValue }) => mapBy(getValue(), "label").join(", "), + }), + columnHelper.accessor("subscriptionExpirationDate", { + header: "Expiration", + cell: (info) => ( + + {!info.getValue() + ? "No expiry date" + : moment(info.getValue()).format("DD/MM/YYYY")} + + ), + }), + columnHelper.accessor("isVerified", { + header: "Verified", + cell: (info) => ( +
+
+ +
+
+ ), + }), + { + header: ( + setShowDemographicInformation((prev) => !prev)} + > + Switch + + ), + id: "actions", + cell: actionColumn, + sortable: false, + }, + ]; - const downloadExcel = (rows: WithLabeledEntities[]) => { - if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.") + const downloadExcel = (rows: WithLabeledEntities[]) => { + if (entitiesDownloadUsers.length === 0) + return toast.error("You are not allowed to download the user list."); - const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e))) - const csv = exportListToExcel(allowedRows); + const allowedRows = rows.filter((r) => + mapBy(r.entities, "id").some((e) => + mapBy(entitiesDownloadUsers, "id").includes(e) + ) + ); + const csv = exportListToExcel(allowedRows); - const element = document.createElement("a"); - const file = new Blob([csv], { type: "text/csv" }); - element.href = URL.createObjectURL(file); - element.download = "users.csv"; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); - }; + const element = document.createElement("a"); + const file = new Blob([csv], { type: "text/csv" }); + element.href = URL.createObjectURL(file); + element.download = "users.csv"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; - const viewStudentFilter = (x: User) => x.type === "student"; - const viewTeacherFilter = (x: User) => x.type === "teacher"; - const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id)); + const viewStudentFilter = (x: User) => x.type === "student"; + const viewTeacherFilter = (x: User) => x.type === "teacher"; + const belongsToAdminFilter = (x: User) => + x.entities.some(({ id }) => + mapBy(selectedUser?.entities || [], "id").includes(id) + ); - const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x); - const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x); + const viewStudentFilterBelongsToAdmin = (x: User) => + viewStudentFilter(x) && belongsToAdminFilter(x); + const viewTeacherFilterBelongsToAdmin = (x: User) => + viewTeacherFilter(x) && belongsToAdminFilter(x); - const renderUserCard = (selectedUser: User) => { - const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); - const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin); - return ( -
- 0 - ? () => { - appendUserFilters({ - id: "view-students", - filter: viewStudentFilter, - }); - appendUserFilters({ - id: "belongs-to-admin", - filter: belongsToAdminFilter, - }); + const renderUserCard = (selectedUser: User) => { + const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); + const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin); + return ( +
+ 0 + ? () => { + appendUserFilters({ + id: "view-students", + filter: viewStudentFilter, + }); + appendUserFilters({ + id: "belongs-to-admin", + filter: belongsToAdminFilter, + }); - router.push("/users"); - } - : undefined - } - onViewTeachers={ - (selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0 - ? () => { - appendUserFilters({ - id: "view-teachers", - filter: viewTeacherFilter, - }); - appendUserFilters({ - id: "belongs-to-admin", - filter: belongsToAdminFilter, - }); + router.push("/users"); + } + : undefined + } + onViewTeachers={ + (selectedUser.type === "corporate" || + selectedUser.type === "student") && + teachersFromAdmin.length > 0 + ? () => { + appendUserFilters({ + id: "view-teachers", + filter: viewTeacherFilter, + }); + appendUserFilters({ + id: "belongs-to-admin", + filter: belongsToAdminFilter, + }); - router.push("/users"); - } - : undefined - } - onViewCorporate={ - selectedUser.type === "teacher" || selectedUser.type === "student" - ? () => { - appendUserFilters({ - id: "view-corporate", - filter: (x: User) => x.type === "corporate", - }); - appendUserFilters({ - id: "belongs-to-admin", - filter: belongsToAdminFilter - }); + router.push("/users"); + } + : undefined + } + onViewCorporate={ + selectedUser.type === "teacher" || selectedUser.type === "student" + ? () => { + appendUserFilters({ + id: "view-corporate", + filter: (x: User) => x.type === "corporate", + }); + appendUserFilters({ + id: "belongs-to-admin", + filter: belongsToAdminFilter, + }); - router.push("/users"); - } - : undefined - } - onClose={(shouldReload) => { - setSelectedUser(undefined); - if (shouldReload) reload(); - }} - user={selectedUser} - /> -
- ); - }; + router.push("/users"); + } + : undefined + } + onClose={(shouldReload) => { + setSelectedUser(undefined); + if (shouldReload) reload(); + }} + user={selectedUser} + /> +
+ ); + }; - return ( - <> - {renderHeader && renderHeader(displayUsers.length)} -
- setSelectedUser(undefined)}> - {selectedUser && renderUserCard(selectedUser)} - - > - data={displayUsers} - columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any} - searchFields={searchFields} - onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined} - /> -
- - ); + return ( + <> + {renderHeader && renderHeader(displayUsers.length)} +
+ setSelectedUser(undefined)} + > + {selectedUser && renderUserCard(selectedUser)} + + > + data={displayUsers} + columns={ + (!showDemographicInformation + ? defaultColumns + : demographicColumns) as any + } + searchFields={searchFields} + onDownload={ + entitiesDownloadUsers.length > 0 ? downloadExcel : undefined + } + isLoading={isLoading} + /> +
+ + ); } diff --git a/src/pages/(admin)/UserCreator.tsx b/src/pages/(admin)/UserCreator.tsx index 7d758a0d..41c23d2c 100644 --- a/src/pages/(admin)/UserCreator.tsx +++ b/src/pages/(admin)/UserCreator.tsx @@ -1,273 +1,272 @@ import Button from "@/components/Low/Button"; import Checkbox from "@/components/Low/Checkbox"; -import { PERMISSIONS } from "@/constants/userPermissions"; -import { CorporateUser, TeacherUser, Type, User } from "@/interfaces/user"; -import { USER_TYPE_LABELS } from "@/resources/user"; +import {PERMISSIONS} from "@/constants/userPermissions"; +import {CorporateUser, TeacherUser, 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 { 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 Input from "@/components/Low/Input"; import CountrySelect from "@/components/Low/CountrySelect"; import useGroups from "@/hooks/useGroups"; import useUsers from "@/hooks/useUsers"; -import { getUserName } from "@/utils/users"; +import {getUserName} from "@/utils/users"; import Select from "@/components/Low/Select"; -import { EntityWithRoles } from "@/interfaces/entity"; +import {EntityWithRoles} from "@/interfaces/entity"; import useEntitiesGroups from "@/hooks/useEntitiesGroups"; +import {mapBy} from "@/utils"; const USER_TYPE_PERMISSIONS: { - [key in Type]: { perm: PermissionType | undefined; list: Type[] }; + [key in Type]: {perm: PermissionType | undefined; list: Type[]}; } = { - student: { - perm: "createCodeStudent", - list: [], - }, - teacher: { - perm: "createCodeTeacher", - list: [], - }, - agent: { - perm: "createCodeCountryManager", - list: ["student", "teacher", "corporate", "mastercorporate"], - }, - corporate: { - perm: "createCodeCorporate", - list: ["student", "teacher"], - }, - mastercorporate: { - perm: undefined, - list: ["student", "teacher", "corporate"], - }, - admin: { - perm: "createCodeAdmin", - list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], - }, - developer: { - perm: undefined, - list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], - }, + student: { + perm: "createCodeStudent", + list: [], + }, + teacher: { + perm: "createCodeTeacher", + list: [], + }, + agent: { + perm: "createCodeCountryManager", + list: ["student", "teacher", "corporate", "mastercorporate"], + }, + corporate: { + perm: "createCodeCorporate", + list: ["student", "teacher"], + }, + mastercorporate: { + perm: undefined, + list: ["student", "teacher", "corporate"], + }, + admin: { + perm: "createCodeAdmin", + list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], + }, + developer: { + perm: undefined, + list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], + }, }; interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[] - permissions: PermissionType[]; - onFinish: () => void; + user: User; + users: User[]; + entities: EntityWithRoles[]; + permissions: PermissionType[]; + onFinish: () => void; } -export default function UserCreator({ user, users, entities = [], permissions, onFinish }: Props) { - const [name, setName] = useState(); - const [email, setEmail] = useState(); - const [phone, setPhone] = useState(); - const [passportID, setPassportID] = useState(); - const [studentID, setStudentID] = useState(); - const [country, setCountry] = useState(user?.demographicInformation?.country); - const [group, setGroup] = useState(); - const [password, setPassword] = useState(); - const [confirmPassword, setConfirmPassword] = useState(); - const [expiryDate, setExpiryDate] = useState( - user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, - ); - const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); - const [isLoading, setIsLoading] = useState(false); - const [type, setType] = useState("student"); - const [position, setPosition] = useState(); - const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) +export default function UserCreator({user, users, entities = [], permissions, onFinish}: Props) { + const [name, setName] = useState(); + const [email, setEmail] = useState(); + const [phone, setPhone] = useState(); + const [passportID, setPassportID] = useState(); + const [studentID, setStudentID] = useState(); + const [country, setCountry] = useState(user?.demographicInformation?.country); + const [group, setGroup] = useState(); + const [password, setPassword] = useState(); + const [confirmPassword, setConfirmPassword] = useState(); + const [expiryDate, setExpiryDate] = useState( + user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, + ); + const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [type, setType] = useState("student"); + const [position, setPosition] = useState(); + const [entity, setEntity] = useState((entities || [])[0]?.id || undefined); - const { groups } = useEntitiesGroups(); + const {groups} = useEntitiesGroups(); - useEffect(() => { - if (!isExpiryDateEnabled) setExpiryDate(null); - }, [isExpiryDateEnabled]); + useEffect(() => { + if (!isExpiryDateEnabled) setExpiryDate(null); + }, [isExpiryDateEnabled]); - const createUser = () => { - if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!"); - if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!"); - if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!"); - if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!"); - if (password !== confirmPassword) return toast.error("The passwords do not match!"); + const createUser = () => { + if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!"); + if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!"); + if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!"); + if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!"); + if (password !== confirmPassword) return toast.error("The passwords do not match!"); - setIsLoading(true); + setIsLoading(true); - const body = { - name, - email, - password, - groupID: group, - entity, - type, - studentID: type === "student" ? studentID : undefined, - expiryDate, - demographicInformation: { - passport_id: type === "student" ? passportID : undefined, - phone, - country, - position, - }, - }; + const body = { + name, + email, + password, + groupID: group, + entity, + type, + studentID: type === "student" ? studentID : undefined, + expiryDate, + demographicInformation: { + passport_id: type === "student" ? passportID : undefined, + phone, + country, + position, + }, + }; - axios - .post("/api/make_user", body) - .then(() => { - toast.success("That user has been created!"); - onFinish(); + axios + .post("/api/make_user", body) + .then(() => { + toast.success("That user has been created!"); + onFinish(); - setName(""); - setEmail(""); - setPhone(""); - setPassportID(""); - setStudentID(""); - setCountry(user?.demographicInformation?.country); - setGroup(null); - setEntity((entities || [])[0]?.id || undefined) - setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null); - setIsExpiryDateEnabled(true); - setType("student"); - setPosition(undefined); - }) - .catch((error) => { - const data = error?.response?.data; - if (!!data?.message) return toast.error(data.message); - toast.error("Something went wrong! Please try again later!"); - }) - .finally(() => setIsLoading(false)); - }; + setName(""); + setEmail(""); + setPhone(""); + setPassportID(""); + setStudentID(""); + setCountry(user?.demographicInformation?.country); + setGroup(null); + setEntity((entities || [])[0]?.id || undefined); + setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null); + setIsExpiryDateEnabled(true); + setType("student"); + setPosition(undefined); + }) + .catch((error) => { + const data = error?.response?.data; + if (!!data?.message) return toast.error(data.message); + toast.error("Something went wrong! Please try again later!"); + }) + .finally(() => setIsLoading(false)); + }; - return ( -
-
- - + return ( +
+
+ + - - + + -
- - -
+
+ + +
- + - {type === "student" && ( - <> - - - - )} + {type === "student" && ( + <> + + + + )} -
- - ({value: e.id, label: e.label}))} + onChange={(e) => setEntity(e?.value || undefined)} + isClearable={checkAccess(user, ["admin", "developer"])} + /> +
- {["corporate", "mastercorporate"].includes(type) && ( - - )} + {["corporate", "mastercorporate"].includes(type) && ( + + )} -
- - x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))} + onChange={(e) => setGroup(e?.value || undefined)} + isClearable + /> +
-
- - {user && ( - - )} -
+
+ + {user && ( + + )} +
-
- {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( - <> -
- - - Enabled - -
- {isExpiryDateEnabled && ( - - moment(date).isAfter(new Date()) && - (user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) - } - dateFormat="dd/MM/yyyy" - selected={expiryDate} - onChange={(date) => setExpiryDate(date)} - /> - )} - - )} -
-
+
+ {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( + <> +
+ + + Enabled + +
+ {isExpiryDateEnabled && ( + + moment(date).isAfter(new Date()) && + (user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) + } + dateFormat="dd/MM/yyyy" + selected={expiryDate} + onChange={(date) => setExpiryDate(date)} + /> + )} + + )} +
+
- -
- ); + +
+ ); } diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index b2f8963c..df4b5286 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -1,9 +1,9 @@ /* eslint-disable @next/next/no-img-element */ import { Module } from "@/interfaces"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import AbandonPopup from "@/components/AbandonPopup"; -import Layout from "@/components/High/Layout"; +import { LayoutContext } from "@/components/High/Layout"; import Finish from "@/exams/Finish"; import Level from "@/exams/Level"; import Listening from "@/exams/Listening"; @@ -11,9 +11,12 @@ import Reading from "@/exams/Reading"; import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; -import { Exam, LevelExam, UserSolution, Variant, WritingExam } from "@/interfaces/exam"; +import { Exam, LevelExam, Variant } from "@/interfaces/exam"; import { User } from "@/interfaces/user"; -import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; +import { + evaluateSpeakingAnswer, + evaluateWritingAnswer, +} from "@/utils/evaluation"; import { getExam } from "@/utils/exams"; import axios from "axios"; import { useRouter } from "next/router"; @@ -24,317 +27,436 @@ import useExamStore from "@/stores/exam"; import useEvaluationPolling from "@/hooks/useEvaluationPolling"; interface Props { - page: "exams" | "exercises"; - user: User; - destination?: string - hideSidebar?: boolean + page: "exams" | "exercises"; + user: User; + destination?: string; + hideSidebar?: boolean; } -export default function ExamPage({ page, user, destination = "/", hideSidebar = false }: Props) { - const router = useRouter(); - const [variant, setVariant] = useState("full"); - const [avoidRepeated, setAvoidRepeated] = useState(false); - const [showAbandonPopup, setShowAbandonPopup] = useState(false); - const [pendingExercises, setPendingExercises] = useState([]); +export default function ExamPage({ + page, + user, + destination = "/", + hideSidebar = false, +}: Props) { + const router = useRouter(); + const [variant, setVariant] = useState("full"); + const [avoidRepeated, setAvoidRepeated] = useState(false); + const [showAbandonPopup, setShowAbandonPopup] = useState(false); + const [moduleLock, setModuleLock] = useState(false); - const { - exam, setExam, - exams, - sessionId, setSessionId, setPartIndex, - moduleIndex, setModuleIndex, - setQuestionIndex, setExerciseIndex, - userSolutions, setUserSolutions, - showSolutions, setShowSolutions, - selectedModules, setSelectedModules, - setUser, - inactivity, - timeSpent, - assignment, - bgColor, - flags, - dispatch, - reset: resetStore, - saveStats, - saveSession, - setFlags, - setShuffles, - evaluated, - } = useExamStore(); + const { + exam, + setExam, + exams, + sessionId, + setSessionId, + setPartIndex, + moduleIndex, + setModuleIndex, + setQuestionIndex, + setExerciseIndex, + userSolutions, + setUserSolutions, + showSolutions, + setShowSolutions, + selectedModules, + setSelectedModules, + setUser, + inactivity, + timeSpent, + assignment, + bgColor, + flags, + dispatch, + reset: resetStore, + saveStats, + saveSession, + setFlags, + setShuffles, + } = useExamStore(); - const [isFetchingExams, setIsFetchingExams] = useState(false); - const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length); + const [isFetchingExams, setIsFetchingExams] = useState(false); + const [isExamLoaded, setIsExamLoaded] = useState( + moduleIndex < selectedModules.length + ); - useEffect(() => { - setIsExamLoaded(moduleIndex < selectedModules.length); - }, [showSolutions, moduleIndex, selectedModules]); + useEffect(() => { + setIsExamLoaded(moduleIndex < selectedModules.length); + }, [showSolutions, moduleIndex, selectedModules]); - useEffect(() => { - if (!showSolutions && sessionId.length === 0 && user?.id) { - const shortUID = new ShortUniqueId(); - setUser(user.id); - setSessionId(shortUID.randomUUID(8)); - } - }, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]); + useEffect(() => { + if (!showSolutions && sessionId.length === 0 && user?.id) { + const shortUID = new ShortUniqueId(); + setUser(user.id); + setSessionId(shortUID.randomUUID(8)); + } + }, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]); - useEffect(() => { - if (user?.type === "developer") console.log(exam); - }, [exam, user]); + useEffect(() => { + if (user?.type === "developer") console.log(exam); + }, [exam, user]); - useEffect(() => { - (async () => { - if (selectedModules.length > 0 && exams.length === 0) { - setIsFetchingExams(true); - const examPromises = selectedModules.map((module) => - getExam( - module, - avoidRepeated, - variant, - user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined, - ), - ); - Promise.all(examPromises).then((values) => { - setIsFetchingExams(false); - if (values.every((x) => !!x)) { - dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } }) - } else { - toast.error("Something went wrong, please try again"); - setTimeout(router.reload, 500); - } - }); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedModules, exams]); + useEffect(() => { + (async () => { + if (selectedModules.length > 0 && exams.length === 0) { + setIsFetchingExams(true); + const examPromises = selectedModules.map((module) => + getExam( + module, + avoidRepeated, + variant, + user?.type === "student" || user?.type === "developer" + ? user.preferredGender + : undefined + ) + ); + Promise.all(examPromises).then((values) => { + setIsFetchingExams(false); + if (values.every((x) => !!x)) { + dispatch({ + type: "INIT_EXAM", + payload: { + exams: values.map((x) => x!), + modules: selectedModules, + }, + }); + } else { + toast.error("Something went wrong, please try again"); + setTimeout(router.reload, 500); + } + }); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedModules, exams]); + const reset = () => { + resetStore(); + setVariant("full"); + setAvoidRepeated(false); + setShowAbandonPopup(false); + }; - const reset = () => { - resetStore(); - setVariant("full"); - setAvoidRepeated(false); - setShowAbandonPopup(false); - }; + useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id); - useEffect(() => { - if (flags.finalizeModule && !showSolutions && flags.pendingEvaluation) { - if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) { - const exercisesToEvaluate = exam.exercises - .map(exercise => exercise.id); + useEffect(() => { + setModuleLock(true); + }, [flags.finalizeModule]); - setPendingExercises(exercisesToEvaluate); - (async () => { - await Promise.all( - exam.exercises.map(async (exercise, index) => { - if (exercise.type === "writing") - await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url); + useEffect(() => { + if (flags.finalizeModule && !showSolutions) { + if ( + exam && + (exam.module === "writing" || exam.module === "speaking") && + userSolutions.length > 0 + ) { + (async () => { + try { + const results = await Promise.all( + exam.exercises.map(async (exercise, index) => { + if (exercise.type === "writing") { + const sol = await evaluateWritingAnswer( + user.id, + sessionId, + exercise, + index + 1, + userSolutions.find((x) => x.exercise === exercise.id)!, + exercise.attachment?.url + ); + return sol; + } + if ( + exercise.type === "interactiveSpeaking" || + exercise.type === "speaking" + ) { + const sol = await evaluateSpeakingAnswer( + user.id, + sessionId, + exercise, + userSolutions.find((x) => x.exercise === exercise.id)!, + index + 1 + ); + return sol; + } + return null; + }) + ); + const updatedSolutions = userSolutions.map((solution) => { + const completed = results + .filter((r) => r !== null) + .find((c: any) => c.exercise === solution.exercise); + return completed || solution; + }); + setUserSolutions(updatedSolutions); + } catch (error) { + console.error("Error during module evaluation:", error); + } finally { + setModuleLock(false); + } + })(); + } else { + setModuleLock(false); + } + } + }, [ + exam, + showSolutions, + userSolutions, + sessionId, + user.id, + flags.finalizeModule, + setUserSolutions, + ]); - if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") { - await evaluateSpeakingAnswer( - user.id, - sessionId, - exercise, - userSolutions.find((x) => x.exercise === exercise.id)!, - index + 1, - ); - } - }), - ) - })(); - } - } - }, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]); + useEffect(() => { + if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) { + (async () => { + setModuleIndex(-1); + await saveStats(); + await axios.get("/api/stats/update"); + })(); + } + }, [ + flags.finalizeExam, + moduleIndex, + saveStats, + setModuleIndex, + userSolutions, + moduleLock, + flags.finalizeModule, + ]); - useEvaluationPolling({ pendingExercises, setPendingExercises }); + useEffect(() => { + if ( + flags.finalizeExam && + !userSolutions.some((s) => s.isDisabled) && + !moduleLock + ) { + setShowSolutions(true); + setFlags({ finalizeExam: false }); + dispatch({ type: "UPDATE_EXAMS" }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]); - useEffect(() => { - if (flags.finalizeExam && moduleIndex !== -1) { - setModuleIndex(-1); + const aggregateScoresByModule = ( + isPractice?: boolean + ): { + module: Module; + total: number; + missing: number; + correct: number; + }[] => { + const scores: { + [key in Module]: { total: number; missing: number; correct: number }; + } = { + reading: { + total: 0, + correct: 0, + missing: 0, + }, + listening: { + total: 0, + correct: 0, + missing: 0, + }, + writing: { + total: 0, + correct: 0, + missing: 0, + }, + speaking: { + total: 0, + correct: 0, + missing: 0, + }, + level: { + total: 0, + correct: 0, + missing: 0, + }, + }; + userSolutions.forEach((x) => { + if (isPractice ? x.isPractice : !x.isPractice) { + const examModule = + x.module || + (x.type === "writing" + ? "writing" + : x.type === "speaking" || x.type === "interactiveSpeaking" + ? "speaking" + : undefined); - } - }, [flags.finalizeExam, moduleIndex, setModuleIndex]); + scores[examModule!] = { + total: scores[examModule!].total + x.score.total, + correct: scores[examModule!].correct + x.score.correct, + missing: scores[examModule!].missing + x.score.missing, + }; + } + }); - useEffect(() => { - if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) { - (async () => { - if (evaluated.length !== 0) { - setUserSolutions( - userSolutions.map(solution => { - const evaluatedSolution = evaluated.find(e => e.exercise === solution.exercise); - if (evaluatedSolution) { - return { ...solution, ...evaluatedSolution }; - } - return solution; - }) - ); - } - await saveStats(); - await axios.get("/api/stats/update"); - setShowSolutions(true); - setFlags({ finalizeExam: false }); - dispatch({ type: "UPDATE_EXAMS" }) - })(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [saveStats, setFlags, setModuleIndex, evaluated, pendingExercises, setUserSolutions, flags]); + return Object.keys(scores).reduce< + { module: Module; total: number; missing: number; correct: number }[] + >((accm, x) => { + if (scores[x as Module].total > 0) + accm.push({ module: x as Module, ...scores[x as Module] }); + return accm; + }, []); + }; + const ModuleExamMap: Record>> = { + reading: Reading as React.ComponentType>, + listening: Listening as React.ComponentType>, + writing: Writing as React.ComponentType>, + speaking: Speaking as React.ComponentType>, + level: Level as React.ComponentType>, + }; - const aggregateScoresByModule = (isPractice?: boolean): { - module: Module; - total: number; - missing: number; - correct: number; - }[] => { - const scores: { - [key in Module]: { total: number; missing: number; correct: number }; - } = { - reading: { - total: 0, - correct: 0, - missing: 0, - }, - listening: { - total: 0, - correct: 0, - missing: 0, - }, - writing: { - total: 0, - correct: 0, - missing: 0, - }, - speaking: { - total: 0, - correct: 0, - missing: 0, - }, - level: { - total: 0, - correct: 0, - missing: 0, - }, - }; + const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined; - userSolutions.filter(x => isPractice ? x.isPractice : !x.isPractice).forEach((x) => { - const examModule = - x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined); + const onAbandon = async () => { + await saveSession(); + reset(); + }; - scores[examModule!] = { - total: scores[examModule!].total + x.score.total, - correct: scores[examModule!].correct + x.score.correct, - missing: scores[examModule!].missing + x.score.missing, - }; - }); + const { + setBgColor, + setHideSidebar, + setFocusMode, + setOnFocusLayerMouseEnter, + } = React.useContext(LayoutContext); - return Object.keys(scores) - .filter((x) => scores[x as Module].total > 0) - .map((x) => ({ module: x as Module, ...scores[x as Module] })); - }; + useEffect(() => { + setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const ModuleExamMap: Record>> = { - "reading": Reading as React.ComponentType>, - "listening": Listening as React.ComponentType>, - "writing": Writing as React.ComponentType>, - "speaking": Speaking as React.ComponentType>, - "level": Level as React.ComponentType>, - } + useEffect(() => { + setBgColor(bgColor); + setHideSidebar(hideSidebar); + setFocusMode( + selectedModules.length !== 0 && + !showSolutions && + moduleIndex < selectedModules.length + ); + }, [ + bgColor, + hideSidebar, + moduleIndex, + selectedModules.length, + setBgColor, + setFocusMode, + setHideSidebar, + showSolutions, + ]); - const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined; - - const onAbandon = async () => { - await saveSession(); - reset(); - }; - - return ( - <> - - {user && ( - setShowAbandonPopup(true)}> - <> - {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} - {selectedModules.length === 0 && { - setModuleIndex(0); - setAvoidRepeated(avoid); - setSelectedModules(modules); - setVariant(variant); - }} - />} - {isFetchingExams && ( -
- - Loading Exam ... -
- )} - {(moduleIndex === -1 && selectedModules.length !== 0) && - { - if (exams[0].module === "level") { - const levelExam = exams[0] as LevelExam; - const allExercises = levelExam.parts.flatMap((part) => part.exercises); - const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index])); - const orderedSolutions = userSolutions.slice().sort((a, b) => { - const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity; - const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity; - return indexA - indexB; - }); - setUserSolutions(orderedSolutions); - } else { - setUserSolutions(userSolutions); - } - setShuffles([]); - if (index === undefined) { - setFlags({ reviewAll: true }); - setModuleIndex(0); - setExam(exams[0]); - } else { - setModuleIndex(index); - setExam(exams[index]); - } - setShowSolutions(true); - setQuestionIndex(0); - setExerciseIndex(0); - setPartIndex(0); - }} - scores={aggregateScoresByModule()} - practiceScores={aggregateScoresByModule(true)} - />} - {/* Exam is on going, display it and the abandon modal */} - {isExamLoaded && moduleIndex !== -1 && ( - <> - {exam && CurrentExam && } - {!showSolutions && setShowAbandonPopup(false)} - /> - } - - )} - -
- )} - - ); + return ( + <> + + {user && ( + <> + {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} + {selectedModules.length === 0 && ( + { + setModuleIndex(0); + setAvoidRepeated(avoid); + setSelectedModules(modules); + setVariant(variant); + }} + /> + )} + {isFetchingExams && ( +
+ + + Loading Exam ... + +
+ )} + {moduleIndex === -1 && selectedModules.length !== 0 && ( + s.isDisabled)} + user={user!} + modules={selectedModules} + solutions={userSolutions} + assignment={assignment} + information={{ + timeSpent, + inactivity, + }} + destination={destination} + onViewResults={(index?: number) => { + if (exams[0].module === "level") { + const levelExam = exams[0] as LevelExam; + const allExercises = levelExam.parts.flatMap( + (part) => part.exercises + ); + const exerciseOrderMap = new Map( + allExercises.map((ex, index) => [ex.id, index]) + ); + const orderedSolutions = userSolutions + .slice() + .sort((a, b) => { + const indexA = + exerciseOrderMap.get(a.exercise) ?? Infinity; + const indexB = + exerciseOrderMap.get(b.exercise) ?? Infinity; + return indexA - indexB; + }); + setUserSolutions(orderedSolutions); + } else { + setUserSolutions(userSolutions); + } + setShuffles([]); + if (index === undefined) { + setFlags({ reviewAll: true }); + setModuleIndex(0); + setExam(exams[0]); + } else { + setModuleIndex(index); + setExam(exams[index]); + } + setShowSolutions(true); + setQuestionIndex(0); + setExerciseIndex(0); + setPartIndex(0); + }} + scores={aggregateScoresByModule()} + practiceScores={aggregateScoresByModule(true)} + /> + )} + {/* Exam is on going, display it and the abandon modal */} + {isExamLoaded && moduleIndex !== -1 && ( + <> + {exam && CurrentExam && ( + + )} + {!showSolutions && ( + setShowAbandonPopup(false)} + /> + )} + + )} + + )} + + ); } diff --git a/src/pages/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx index 5dfc9842..7ff8b626 100644 --- a/src/pages/(status)/PaymentDue.tsx +++ b/src/pages/(status)/PaymentDue.tsx @@ -1,18 +1,14 @@ /* eslint-disable @next/next/no-img-element */ -import Layout from "@/components/High/Layout"; -import useGroups from "@/hooks/useGroups"; -import usePackages from "@/hooks/usePackages"; import useUsers from "@/hooks/useUsers"; import { User } from "@/interfaces/user"; import clsx from "clsx"; -import { capitalize, sortBy } from "lodash"; +import { capitalize } from "lodash"; import { useEffect, useMemo, useState } from "react"; import useInvites from "@/hooks/useInvites"; import { BsArrowRepeat } from "react-icons/bs"; import InviteCard from "@/components/Medium/InviteCard"; import { useRouter } from "next/router"; import { ToastContainer } from "react-toastify"; -import useDiscounts from "@/hooks/useDiscounts"; import PaymobPayment from "@/components/PaymobPayment"; import moment from "moment"; import { EntityWithRoles } from "@/interfaces/entity"; @@ -22,241 +18,345 @@ import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import Select from "@/components/Low/Select"; interface Props { - user: User - discounts: Discount[] - packages: Package[] - entities: EntityWithRoles[] - hasExpired?: boolean; - reload: () => void; + user: User; + discounts: Discount[]; + packages: Package[]; + entities: EntityWithRoles[]; + hasExpired?: boolean; + reload: () => void; } -export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) { - const [isLoading, setIsLoading] = useState(false); - const [entity, setEntity] = useState() +export default function PaymentDue({ + user, + discounts = [], + entities = [], + packages = [], + hasExpired = false, + reload, +}: Props) { + const [isLoading, setIsLoading] = useState(false); + const [entity, setEntity] = useState(); - const router = useRouter(); + const router = useRouter(); - const { users } = useUsers(); - const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); + const { users } = useUsers(); + const { + invites, + isLoading: isInvitesLoading, + reload: reloadInvites, + } = useInvites({ to: user?.id }); - const isIndividual = useMemo(() => { - if (isAdmin(user)) return false; - if (user?.type !== "student") return false; + const isIndividual = useMemo(() => { + if (isAdmin(user)) return false; + if (user?.type !== "student") return false; - return user.entities.length === 0 - }, [user]) + return user.entities.length === 0; + }, [user]); - const appliedDiscount = useMemo(() => { - const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift(); + const appliedDiscount = useMemo(() => { + const biggestDiscount = [...discounts] + .sort((a, b) => b.percentage - a.percentage) + .shift(); - if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) - return 0; + if ( + !biggestDiscount || + (biggestDiscount.validUntil && + moment(biggestDiscount.validUntil).isBefore(moment())) + ) + return 0; - return biggestDiscount.percentage - }, [discounts]) + return biggestDiscount.percentage; + }, [discounts]); - const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity') + const entitiesThatCanBePaid = useAllowedEntities( + user, + entities, + "pay_entity" + ); - useEffect(() => { - if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]) - }, [entitiesThatCanBePaid]) + useEffect(() => { + if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]); + }, [entitiesThatCanBePaid]); - return ( - <> - - {isLoading && ( -
-
- - Completing your payment... - If you canceled your payment or it failed, please click the button below to restart - -
-
- )} - - {invites.length > 0 && ( -
-
-
- Invites - -
-
- - {invites.map((invite) => ( - { - reloadInvites(); - router.reload(); - }} - /> - ))} - -
- )} + return ( + <> + + {isLoading && ( +
+
+ + + Completing your payment... + + + If you canceled your payment or it failed, please click the button + below to restart + + +
+
+ )} + <> + {invites.length > 0 && ( +
+
+
+ + Invites + + +
+
+ + {invites.map((invite) => ( + { + reloadInvites(); + router.reload(); + }} + /> + ))} + +
+ )} -
- {hasExpired && You do not have time credits for your account type!} - {isIndividual && ( -
- - To add to your use of EnCoach, please purchase one of the time packages available below: - -
- {packages.map((p) => ( -
-
- EnCoach's Logo - - EnCoach - {p.duration}{" "} - {capitalize( - p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, - )} - -
-
- {appliedDiscount === 0 && ( - - {p.price} {p.currency} - - )} - {appliedDiscount > 0 && ( -
- - {p.price} {p.currency} - - - {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} - -
- )} - { - setTimeout(reload, 500); - }} - currency={p.currency} - duration={p.duration} - duration_unit={p.duration_unit} - price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} - /> -
-
- This includes: -
    -
  • - Train your abilities for the IELTS exam
  • -
  • - Gain insights into your weaknesses and strengths
  • -
  • - Allow yourself to correctly prepare for the exam
  • -
-
-
- ))} -
-
- )} +
+ {hasExpired && ( + + You do not have time credits for your account type! + + )} + {isIndividual && ( +
+ + To add to your use of EnCoach, please purchase one of the time + packages available below: + +
+ {packages.map((p) => ( +
+
+ EnCoach's Logo + + EnCoach - {p.duration}{" "} + {capitalize( + p.duration === 1 + ? p.duration_unit.slice( + 0, + p.duration_unit.length - 1 + ) + : p.duration_unit + )} + +
+
+ {appliedDiscount === 0 && ( + + {p.price} {p.currency} + + )} + {appliedDiscount > 0 && ( +
+ + {p.price} {p.currency} + + + {( + p.price - + p.price * (appliedDiscount / 100) + ).toFixed(2)}{" "} + {p.currency} + +
+ )} + { + setTimeout(reload, 500); + }} + currency={p.currency} + duration={p.duration} + duration_unit={p.duration_unit} + price={ + +( + p.price - + p.price * (appliedDiscount / 100) + ).toFixed(2) + } + /> +
+
+ This includes: +
    +
  • - Train your abilities for the IELTS exam
  • +
  • + - Gain insights into your weaknesses and strengths +
  • +
  • + - Allow yourself to correctly prepare for the exam +
  • +
+
+
+ ))} +
+
+ )} - {!isIndividual && entitiesThatCanBePaid.length > 0 && - entity?.payment && ( -
-
- - ({ + value: e.id, + label: e.label, + entity: e, + }))} + onChange={(e) => (e?.value ? setEntity(e?.entity) : null)} + className="!w-full max-w-[400px] self-center" + /> +
- - To add to your use of EnCoach and that of your students and teachers, please pay your designated package - below: - -
-
- EnCoach's Logo - - EnCoach - {12} Months - -
-
- - {entity.payment.price} {entity.payment.currency} - - { - setIsLoading(false); - setTimeout(reload, 500); - }} - /> -
-
- This includes: -
    -
  • - - Allow a total of {entity.licenses} students and teachers to use EnCoach -
  • -
  • - Train their abilities for the IELTS exam
  • -
  • - Gain insights into your students' weaknesses and strengths
  • -
  • - Allow them to correctly prepare for the exam
  • -
-
-
-
- )} - {!isIndividual && entitiesThatCanBePaid.length === 0 && ( -
- - You are not the person in charge of your time credits, please contact your administrator about this situation. - - - If you believe this to be a mistake, please contact the platform's administration, thank you for your - patience. - -
- )} - {!isIndividual && - entitiesThatCanBePaid.length > 0 && - !entity?.payment && ( -
-
- - ({ + value: e.id, + label: e.label, + entity: e, + }))} + onChange={(e) => (e?.value ? setEntity(e?.entity) : null)} + className="!w-full max-w-[400px] self-center" + /> +
+ + An admin nor your agent have yet set the price intended to + your requirements in terms of the amount of users you desire + and your expected monthly duration. + + + Please try again later or contact your agent or an admin, + thank you for your patience. + +
+ )} +
+ + + ); } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index b12f32a5..b5cdc2c9 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,35 +1,74 @@ import "@/styles/globals.css"; import "react-toastify/dist/ReactToastify.css"; -import type {AppProps} from "next/app"; +import type { AppProps } from "next/app"; import "primereact/resources/themes/lara-light-indigo/theme.css"; import "primereact/resources/primereact.min.css"; import "primeicons/primeicons.css"; import "react-datepicker/dist/react-datepicker.css"; -import {useRouter} from "next/router"; -import {useEffect} from "react"; +import { Router, useRouter } from "next/router"; +import { useEffect, useState } from "react"; import useExamStore from "@/stores/exam"; import usePreferencesStore from "@/stores/preferencesStore"; +import Layout from "../components/High/Layout"; +import useEntities from "../hooks/useEntities"; +export default function App({ Component, pageProps }: AppProps) { + const [loading, setLoading] = useState(false); -export default function App({Component, pageProps}: AppProps) { - const {reset} = useExamStore(); - const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized); + const { reset } = useExamStore(); - const router = useRouter(); + const setIsSidebarMinimized = usePreferencesStore( + (state) => state.setSidebarMinimized + ); - useEffect(() => { - if (router.pathname !== "/exam" && router.pathname !== "/exercises") reset(); - }, [router.pathname, reset]); + const router = useRouter(); - useEffect(() => { - if (localStorage.getItem("isSidebarMinimized")) { - if (localStorage.getItem("isSidebarMinimized") === "true") { - setIsSidebarMinimized(true); - } else { - setIsSidebarMinimized(false); - } - } - }, [setIsSidebarMinimized]); + const { entities } = useEntities(!pageProps?.user?.id); - return ; + useEffect(() => { + const start = () => { + setLoading(true); + }; + const end = () => { + setLoading(false); + }; + Router.events.on("routeChangeStart", start); + Router.events.on("routeChangeComplete", end); + Router.events.on("routeChangeError", end); + return () => { + Router.events.off("routeChangeStart", start); + Router.events.off("routeChangeComplete", end); + Router.events.off("routeChangeError", end); + }; + }, []); + + useEffect(() => { + if (router.pathname !== "/exam" && router.pathname !== "/exercises") + reset(); + }, [router.pathname, reset]); + + useEffect(() => { + if (localStorage.getItem("isSidebarMinimized")) { + if (localStorage.getItem("isSidebarMinimized") === "true") { + setIsSidebarMinimized(true); + } else { + setIsSidebarMinimized(false); + } + } + }, [setIsSidebarMinimized]); + + return pageProps?.user ? ( + + {loading ? ( + // TODO: Change this later to a better loading screen (example: skeletons for each page) +
+ +
+ ) : ( + + )} +
+ ) : ( + + ); } diff --git a/src/pages/api/approval-workflows/[id]/edit.ts b/src/pages/api/approval-workflows/[id]/edit.ts new file mode 100644 index 00000000..e675addb --- /dev/null +++ b/src/pages/api/approval-workflows/[id]/edit.ts @@ -0,0 +1,32 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; +import { sessionOptions } from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { updateApprovalWorkflow } from "@/utils/approval.workflows.be"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { ObjectId } from "mongodb"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "PUT") return await put(req, res); +} + +async function put(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + const { id } = req.query as { id?: string }; + const approvalWorkflow: ApprovalWorkflow = req.body; + + if (id && approvalWorkflow) { + approvalWorkflow._id = new ObjectId(id); + await updateApprovalWorkflow("active-workflows", approvalWorkflow); + return res.status(204).end(); + } +} diff --git a/src/pages/api/approval-workflows/[id]/index.ts b/src/pages/api/approval-workflows/[id]/index.ts new file mode 100644 index 00000000..d6832587 --- /dev/null +++ b/src/pages/api/approval-workflows/[id]/index.ts @@ -0,0 +1,74 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; +import { sessionOptions } from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { deleteApprovalWorkflow, getApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be"; +import { getEntityWithRoles } from "@/utils/entities.be"; +import { doesEntityAllow } from "@/utils/permissions"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { ObjectId } from "mongodb"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "DELETE") return await del(req, res); + if (req.method === "PUT") return await put(req, res); + if (req.method === "GET") return await get(req, res); +} + +async function del(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + const { id } = req.query as { id: string }; + const workflow = await getApprovalWorkflow("active-workflows", id); + + if (!workflow) return res.status(404).json({ ok: false }); + + const entity = await getEntityWithRoles(workflow.entityId); + if (!entity) return res.status(404).json({ ok: false }); + + if (!doesEntityAllow(user, entity, "delete_workflow") && !["admin", "developer"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + return res.status(200).json(await deleteApprovalWorkflow("active-workflows", id)); +} + +async function put(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + const { id } = req.query as { id?: string }; + const workflow: ApprovalWorkflow = req.body; + + if (id && workflow) { + workflow._id = new ObjectId(id); + await updateApprovalWorkflow("active-workflows", workflow); + return res.status(204).end(); + } +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + const { id } = req.query as { id?: string }; + + if (id) { + return res.status(200).json(await getApprovalWorkflow("active-workflows", id)); + } +} diff --git a/src/pages/api/approval-workflows/create.ts b/src/pages/api/approval-workflows/create.ts new file mode 100644 index 00000000..aa88f9348 --- /dev/null +++ b/src/pages/api/approval-workflows/create.ts @@ -0,0 +1,37 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; +import { Entity } from "@/interfaces/entity"; +import { sessionOptions } from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { replaceApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be"; +import { withIronSessionApiRoute } from "iron-session/next"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +interface ReplaceApprovalWorkflowsRequest { + filteredWorkflows: ApprovalWorkflow[]; + userEntitiesWithLabel: Entity[]; +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return await post(req, res); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + const { filteredWorkflows, userEntitiesWithLabel } = req.body as ReplaceApprovalWorkflowsRequest; + + const configuredWorkflows: ApprovalWorkflow[] = filteredWorkflows; + const entitiesIds: string[] = userEntitiesWithLabel.map((e) => e.id); + + await replaceApprovalWorkflowsByEntities(configuredWorkflows, entitiesIds); + + return res.status(204).end(); +} diff --git a/src/pages/api/approval-workflows/index.ts b/src/pages/api/approval-workflows/index.ts new file mode 100644 index 00000000..ff381a5e --- /dev/null +++ b/src/pages/api/approval-workflows/index.ts @@ -0,0 +1,23 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { sessionOptions } from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { getApprovalWorkflows } from "@/utils/approval.workflows.be"; +import { withIronSessionApiRoute } from "iron-session/next"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + return res.status(200).json(await getApprovalWorkflows("active-workflows")); +} \ No newline at end of file diff --git a/src/pages/api/evaluate/fetchSolutions.ts b/src/pages/api/evaluate/fetchSolutions.ts index 05045bca..18d9a4c5 100644 --- a/src/pages/api/evaluate/fetchSolutions.ts +++ b/src/pages/api/evaluate/fetchSolutions.ts @@ -4,21 +4,71 @@ import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import { UserSolution } from "@/interfaces/exam"; import { speakingReverseMarking, writingReverseMarking } from "@/utils/score"; +import { Stat } from "@/interfaces/user"; const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); -} - -async function post(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { res.status(401).json({ ok: false }); return; } - const { sessionId, userId, userSolutions } = req.body; + try { + return await getSessionEvals(req, res); + } catch (error) { + console.error(error); + res.status(500).json({ ok: false }); + } +} + +function formatSolutionWithEval(userSolution: UserSolution | Stat, evaluation: any) { + if (userSolution.type === 'writing') { + return { + ...userSolution, + solutions: [{ + ...userSolution.solutions[0], + evaluation: evaluation.result + }], + score: { + correct: writingReverseMarking[evaluation.result.overall], + total: 100, + missing: 0 + }, + isDisabled: false + }; + } + + if (userSolution.type === 'speaking' || userSolution.type === 'interactiveSpeaking') { + return { + ...userSolution, + solutions: [{ + ...userSolution.solutions[0], + ...( + userSolution.type === 'speaking' + ? { fullPath: evaluation.result.fullPath } + : { answer: evaluation.result.answer } + ), + evaluation: evaluation.result + }], + score: { + correct: speakingReverseMarking[evaluation.result.overall || 0] || 0, + total: 100, + missing: 0 + }, + isDisabled: false + }; + } + + return { + solution: userSolution, + evaluation + }; +} + +async function getSessionEvals(req: NextApiRequest, res: NextApiResponse) { + const { sessionId, userId, stats } = req.body; const completedEvals = await db.collection("evaluation").find({ session_id: sessionId, user: userId, @@ -29,52 +79,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) { completedEvals.map(e => [e.exercise_id, e]) ); - const solutionsWithEvals = userSolutions.filter((solution: UserSolution) => - evalsByExercise.has(solution.exercise) - ).map((solution: any) => { - const evaluation = evalsByExercise.get(solution.exercise)!; + const statsWithEvals = stats + .filter((solution: UserSolution | Stat) => evalsByExercise.has(solution.exercise)) + .map((solution: UserSolution | Stat) => + formatSolutionWithEval(solution, evalsByExercise.get(solution.exercise)!) + ); - if (solution.type === 'writing') { - return { - ...solution, - solutions: [{ - ...solution.solutions[0], - evaluation: evaluation.result - }], - score: { - correct: writingReverseMarking[evaluation.result.overall], - total: 100, - missing: 0 - }, - isDisabled: false - }; - } - - if (solution.type === 'speaking' || solution.type === 'interactiveSpeaking') { - return { - ...solution, - solutions: [{ - ...solution.solutions[0], - ...( - solution.type === 'speaking' - ? { fullPath: evaluation.result.fullPath } - : { answer: evaluation.result.answer } - ), - evaluation: evaluation.result - }], - score: { - correct: speakingReverseMarking[evaluation.result.overall || 0] || 0, - total: 100, - missing: 0 - }, - isDisabled: false - }; - } - return { - solution, - evaluation - }; - }); - - res.status(200).json(solutionsWithEvals) + res.status(200).json(statsWithEvals); } diff --git a/src/pages/api/evaluate/status.ts b/src/pages/api/evaluate/status.ts index 9679a545..dbc089bf 100644 --- a/src/pages/api/evaluate/status.ts +++ b/src/pages/api/evaluate/status.ts @@ -11,19 +11,100 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "GET") return get(req, res); } +type Query = { + op: string; + sessionId: string; + userId: string; +} + async function get(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ ok: false }); - return; + return res.status(401).json({ ok: false }); } - const { sessionId, userId } = req.query; + const { sessionId, userId, op } = req.query as Query; + switch (op) { + case 'pending': + return getPendingEvaluation(userId, sessionId, res); + case 'disabled': + return getSessionsWIthDisabledWithPending(userId, res); + default: + return res.status(400).json({ + ok: false, + }); + } +} + +async function getPendingEvaluation( + userId: string, + sessionId: string, + res: NextApiResponse +) { const singleEval = await db.collection("evaluation").findOne({ session_id: sessionId, user: userId, status: "pending", }); - - res.status(200).json({ hasPendingEvaluation: singleEval !== null}); + return res.status(200).json({ hasPendingEvaluation: singleEval !== null }); +} + +async function getSessionsWIthDisabledWithPending( + userId: string, + res: NextApiResponse +) { + const sessions = await db.collection("stats") + .aggregate([ + { + $match: { + user: userId, + disabled: true + } + }, + { + $project: { + _id: 0, + session: 1 + } + }, + { + $lookup: { + from: "evaluation", + let: { sessionId: "$session" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$session", "$$sessionId"] }, + { $eq: ["$user", userId] }, + { $eq: ["$status", "pending"] } + ] + } + } + }, + { + $project: { + _id: 1 + } + } + ], + as: "pendingEvals" + } + }, + { + $match: { + "pendingEvals.0": { $exists: true } + } + }, + { + $group: { + id: "$session" + } + } + ]).toArray(); + + return res.status(200).json({ + sessions: sessions.map(s => s.id) + }); } diff --git a/src/pages/api/exam/[module]/index.ts b/src/pages/api/exam/[module]/index.ts index 563b953b..e78986e9 100644 --- a/src/pages/api/exam/[module]/index.ts +++ b/src/pages/api/exam/[module]/index.ts @@ -1,15 +1,17 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from "next"; -import client from "@/lib/mongodb"; -import { withIronSessionApiRoute } from "iron-session/next"; -import { sessionOptions } from "@/lib/session"; -import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; -import { getExams } from "@/utils/exams.be"; import { Module } from "@/interfaces"; -import { getUserCorporate } from "@/utils/groups.be"; -import { requestUser } from "@/utils/api"; -import { isAdmin } from "@/utils/users"; +import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; +import { createApprovalWorkflowsOnExamCreation } from "@/lib/createWorkflowsOnExamCreation"; +import client from "@/lib/mongodb"; +import { sessionOptions } from "@/lib/session"; import { mapBy } from "@/utils"; +import { requestUser } from "@/utils/api"; +import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/approval.workflows.be"; +import { generateExamDifferences } from "@/utils/exam.differences"; +import { getExams } from "@/utils/exams.be"; +import { isAdmin } from "@/utils/users"; +import { withIronSessionApiRoute } from "iron-session/next"; +import type { NextApiRequest, NextApiResponse } from "next"; const db = client.db(process.env.MONGODB_DB); @@ -40,13 +42,13 @@ async function GET(req: NextApiRequest, res: NextApiResponse) { } async function POST(req: NextApiRequest, res: NextApiResponse) { - const user = await requestUser(req, res) + const user = await requestUser(req, res); if (!user) return res.status(401).json({ ok: false }); const { module } = req.query as { module: string }; const session = client.startSession(); - const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id') + const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); // might need to change this with new approval workflows logic.. if an admin creates an exam no workflow is started because workflows must have entities configured. try { const exam = { @@ -57,14 +59,20 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { createdAt: new Date().toISOString(), }; + let responseStatus: number; + let responseMessage: string; + await session.withTransaction(async () => { const docSnap = await db.collection(module).findOne({ id: req.body.id }, { session }); // Check whether the id of the exam matches another exam with different // owners, throw exception if there is, else allow editing - const ownersSet = new Set(docSnap?.owners || []); + const existingExamOwners = docSnap?.owners ?? []; + const newExamOwners = exam.owners ?? []; - if (docSnap !== null && docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) { + const ownersSet = new Set(existingExamOwners); + + if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) { throw new Error("Name already exists"); } @@ -73,13 +81,59 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { { $set: { id: req.body.id, ...exam } }, { upsert: true, - session + session, } ); + + // if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response. + responseStatus = 200; + responseMessage = `Successfully updated exam with ID: "${exam.id}"`; + // TODO maybe find a way to start missing approval workflows in case they were only configured after exam creation. + + // create workflow only if exam is being created for the first time + if (docSnap === null) { + try { + const { successCount, totalCount } = await createApprovalWorkflowsOnExamCreation(exam.createdBy, exam.entities, exam.id, module); + + if (successCount === totalCount) { + responseStatus = 200; + responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s)`; + } else if (successCount > 0) { + responseStatus = 207; + responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities`; + } else { + responseStatus = 207; + responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to find any configured Approval Workflow for the author.`; + } + } catch (error) { + console.error("Workflow creation error:", error); + responseStatus = 207; + responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`; + } + } else { // if exam was updated, log the updates + const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id); + + if (approvalWorkflows) { + const differences = generateExamDifferences(docSnap as Exam, exam as Exam); + if (differences) { + approvalWorkflows.forEach((workflow) => { + const currentStepIndex = workflow.steps.findIndex(step => !step.completed || step.rejected); + + if (workflow.steps[currentStepIndex].examChanges === undefined) { + workflow.steps[currentStepIndex].examChanges = [...differences]; + } else { + workflow.steps[currentStepIndex].examChanges!.push(...differences); + } + }); + await updateApprovalWorkflows("active-workflows", approvalWorkflows); + } + } + } + + res.status(responseStatus).json({ + message: responseMessage, + }); }); - - res.status(200).json(exam); - } catch (error) { console.error("Transaction failed: ", error); res.status(500).json({ ok: false, error: (error as any).message }); diff --git a/src/pages/api/sessions/index.ts b/src/pages/api/sessions/index.ts index a352ea78..68ac3b08 100644 --- a/src/pages/api/sessions/index.ts +++ b/src/pages/api/sessions/index.ts @@ -26,7 +26,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) { const q = user ? { user: user } : {}; const sessions = await db.collection("sessions").find({ ...q, - }).limit(12).toArray(); + }).limit(12).sort({ date: -1 }).toArray(); console.log(sessions) res.status(200).json( diff --git a/src/pages/api/stats/updateDisabled.ts b/src/pages/api/stats/disabled.ts similarity index 74% rename from src/pages/api/stats/updateDisabled.ts rename to src/pages/api/stats/disabled.ts index ba7e688c..3fa6b756 100644 --- a/src/pages/api/stats/updateDisabled.ts +++ b/src/pages/api/stats/disabled.ts @@ -3,37 +3,41 @@ import type { NextApiRequest, NextApiResponse } from "next"; import client from "@/lib/mongodb"; import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; -import { Stat } from "@/interfaces/user"; import { requestUser } from "@/utils/api"; import { UserSolution } from "@/interfaces/exam"; +import { WithId } from "mongodb"; const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); -} - -interface Body { - solutions: UserSolution[]; - sessionID: string; -} - -async function post(req: NextApiRequest, res: NextApiResponse) { const user = await requestUser(req, res) if (!user) return res.status(401).json({ ok: false }); - const { solutions, sessionID } = req.body as Body; + if (req.method === "POST") return post(req, res); +} - const disabledStats = await db.collection("stats").find({ user: user.id, session: sessionID, disabled: true }).toArray(); + +interface Body { + solutions: UserSolution[]; + sessionId: string; + userId: string; +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const { userId, solutions, sessionId } = req.body as Body; + const disabledStats = await db.collection("stats").find( + { user: userId, session: sessionId, isDisabled: true } + ).toArray(); await Promise.all(disabledStats.map(async (stat) => { const matchingSolution = solutions.find(s => s.exercise === stat.exercise); if (matchingSolution) { + const { _id, ...updateFields } = matchingSolution as WithId; await db.collection("stats").updateOne( { id: stat.id }, - { $set: { ...matchingSolution } } + { $set: { ...updateFields } } ); } })); diff --git a/src/pages/api/stats/session/[session].ts b/src/pages/api/stats/session/[session].ts new file mode 100644 index 00000000..2d3ff16e --- /dev/null +++ b/src/pages/api/stats/session/[session].ts @@ -0,0 +1,21 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import client from "@/lib/mongodb"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {session} = req.query; + const snapshot = await db.collection("stats").find({ user: req.session.user.id, session }).toArray(); + + res.status(200).json(snapshot); +} \ No newline at end of file diff --git a/src/pages/api/stats/user/[user].ts b/src/pages/api/stats/user/[user].ts index 1f8352d6..3bc33e74 100644 --- a/src/pages/api/stats/user/[user].ts +++ b/src/pages/api/stats/user/[user].ts @@ -1,21 +1,20 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type {NextApiRequest, NextApiResponse} from "next"; -import client from "@/lib/mongodb"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { getDetailedStatsByUser } from "../../../../utils/stats.be"; -const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ok: false}); + res.status(401).json({ ok: false }); return; } - const {user} = req.query; - const snapshot = await db.collection("stats").find({ user: user }).toArray(); + const { user, query } = req.query as { user: string, query?: string }; + const snapshot = await getDetailedStatsByUser(user, query); res.status(200).json(snapshot); } \ No newline at end of file diff --git a/src/pages/api/tickets/assignedToUser/index.ts b/src/pages/api/tickets/assignedToUser/index.ts new file mode 100644 index 00000000..c362ba2a --- /dev/null +++ b/src/pages/api/tickets/assignedToUser/index.ts @@ -0,0 +1,39 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { Ticket, TicketWithCorporate } from "@/interfaces/ticket"; +import { sessionOptions } from "@/lib/session"; +import client from "@/lib/mongodb"; +import { withIronSessionApiRoute } from "iron-session/next"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { Group, CorporateUser } from "@/interfaces/user"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + + // specific logic for the preflight request + if (req.method === "OPTIONS") { + res.status(200).end(); + return; + } + + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + if (req.method === "GET") { + await get(req, res); + } +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + const docs = await db.collection("tickets").find({ assignedTo: req.session.user.id, status: { $ne: "completed" } }).toArray(); + + res.status(200).json(docs); +} diff --git a/src/pages/api/users/search.ts b/src/pages/api/users/search.ts new file mode 100644 index 00000000..51a0b922 --- /dev/null +++ b/src/pages/api/users/search.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { searchUsers } from "@/utils/users.be"; +import { Type } from "@/interfaces/user"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user && !req.headers["page"] && req.headers["page"] !== "register") { + res.status(401).json({ ok: false }); + return; + } + + const { + value, + size, + page, + orderBy = "name", + direction = "asc", + type, + entities + } = req.query as { value?: string, size?: string; type?: Type; page?: string; orderBy?: string; direction?: "asc" | "desc", entities?: string }; + + const { users, total } = await searchUsers( + value, + size !== undefined ? parseInt(size) : undefined, + page !== undefined ? parseInt(page) : undefined, + { + [orderBy]: direction === "asc" ? 1 : -1, + }, + {}, + { + ...(type ? { "type": type } : {}), + ...(entities ? { "entities.id": entities.split(',') } : {}) + } + ); + res.status(200).json({ users, total }); +} \ No newline at end of file diff --git a/src/pages/approval-workflows/[id]/edit.tsx b/src/pages/approval-workflows/[id]/edit.tsx new file mode 100644 index 00000000..aec4fd1f --- /dev/null +++ b/src/pages/approval-workflows/[id]/edit.tsx @@ -0,0 +1,197 @@ +import RequestedBy from "@/components/ApprovalWorkflows/RequestedBy"; +import StartedOn from "@/components/ApprovalWorkflows/StartedOn"; +import Status from "@/components/ApprovalWorkflows/Status"; +import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm"; +import Layout from "@/components/High/Layout"; +import { ApprovalWorkflow, EditableApprovalWorkflow, EditableWorkflowStep, getUserTypeLabelShort } from "@/interfaces/approval.workflow"; +import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import { findBy, redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; +import { getApprovalWorkflow } from "@/utils/approval.workflows.be"; +import { getEntityWithRoles } from "@/utils/entities.be"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; +import { doesEntityAllow } from "@/utils/permissions"; +import { getEntityUsers } from "@/utils/users.be"; +import axios from "axios"; +import { LayoutGroup, motion } from "framer-motion"; +import { withIronSessionSsr } from "iron-session/next"; +import Head from "next/head"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { BsChevronLeft } from "react-icons/bs"; +import { toast, ToastContainer } from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { + const user = await requestUser(req, res); + if (!user) return redirect("/login") + + if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) + return redirect("/") + + const { id } = params as { id: string }; + + const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id); + if (!workflow) return redirect("/approval-workflows"); + + const entityWithRole = await getEntityWithRoles(workflow.entityId); + if (!entityWithRole) return redirect("/approval-workflows"); + + if (!doesEntityAllow(user, entityWithRole, "edit_workflow")) return redirect("/approval-workflows"); + + return { + props: serialize({ + user, + workflow, + workflowEntityApprovers: await getEntityUsers(workflow.entityId, undefined, { type: { $in: ["teacher", "corporate", "mastercorporate", "developer"] } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[], + }), + }; +}, sessionOptions); + +interface Props { + user: User, + workflow: ApprovalWorkflow, + workflowEntityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[], +} + +export default function Home({ user, workflow, workflowEntityApprovers }: Props) { + const [updatedWorkflow, setUpdatedWorkflow] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const editableSteps: EditableWorkflowStep[] = workflow.steps.map(step => ({ + key: step.stepNumber + 999, // just making sure they are unique because new steps that users add will have key=3 key=4 etc + stepType: step.stepType, + stepNumber: step.stepNumber, + completed: step.completed, + completedBy: step.completedBy || undefined, + completedDate: step.completedDate || undefined, + assignees: step.assignees, + firstStep: step.firstStep || false, + finalStep: step.finalStep || false, + onDelete: undefined, + })); + + const editableWorkflow: EditableApprovalWorkflow = { + id: workflow._id?.toString() ?? "", + name: workflow.name, + entityId: workflow.entityId, + requester: user.id, // should it change to the editor? + startDate: workflow.startDate, + modules: workflow.modules, + status: workflow.status, + steps: editableSteps, + }; + + setUpdatedWorkflow(editableWorkflow); + }, []); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + if (!updatedWorkflow) { + setIsLoading(false); + return; + } + + for (const step of updatedWorkflow.steps) { + if (step.assignees.every(x => !x)) { + toast.warning("There is at least one empty step in the workflow."); + setIsLoading(false); + return; + } + } + + const filteredWorkflow: ApprovalWorkflow = { + ...updatedWorkflow, + steps: updatedWorkflow.steps.map(step => ({ + ...step, + assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined) + })) + }; + + axios + .put(`/api/approval-workflows/${updatedWorkflow.id}/edit`, filteredWorkflow) + .then(() => { + toast.success("Approval Workflow edited successfully."); + setIsLoading(false); + }) + .catch((reason) => { + if (reason.response.status === 401) { + toast.error("Not logged in!"); + } else if (reason.response.status === 403) { + toast.error("You do not have permission to edit Approval Workflows!"); + } else { + toast.error("Something went wrong, please try again later."); + } + setIsLoading(false); + console.log("Submitted Values:", filteredWorkflow); + return; + }) + }; + + const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => { + setUpdatedWorkflow(updatedWorkflow); + }; + + return ( + <> + + Edit Workflow | EnCoach + + + + + +
+ + + +

{workflow.name}

+
+ +
+
+ + + +
+
+ +
+ + + {updatedWorkflow && + + } + + +
+ + ); +} diff --git a/src/pages/approval-workflows/[id]/index.tsx b/src/pages/approval-workflows/[id]/index.tsx new file mode 100644 index 00000000..6cdaec9a --- /dev/null +++ b/src/pages/approval-workflows/[id]/index.tsx @@ -0,0 +1,608 @@ +import RequestedBy from "@/components/ApprovalWorkflows/RequestedBy"; +import StartedOn from "@/components/ApprovalWorkflows/StartedOn"; +import Status from "@/components/ApprovalWorkflows/Status"; +import Tip from "@/components/ApprovalWorkflows/Tip"; +import UserWithProfilePic from "@/components/ApprovalWorkflows/UserWithProfilePic"; +import WorkflowStepComponent from "@/components/ApprovalWorkflows/WorkflowStepComponent"; +import Layout from "@/components/High/Layout"; +import Button from "@/components/Low/Button"; +import useApprovalWorkflow from "@/hooks/useApprovalWorkflow"; +import { ApprovalWorkflow, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow"; +import { User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import useExamStore from "@/stores/exam"; +import { redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; +import { getApprovalWorkflow } from "@/utils/approval.workflows.be"; +import { getEntityWithRoles } from "@/utils/entities.be"; +import { getExamById } from "@/utils/exams"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; +import { doesEntityAllow } from "@/utils/permissions"; +import { getSpecificUsers, getUser } from "@/utils/users.be"; +import axios from "axios"; +import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; +import { withIronSessionSsr } from "iron-session/next"; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { BsChevronLeft } from "react-icons/bs"; +import { FaSpinner, FaWpforms } from "react-icons/fa6"; +import { FiSave } from "react-icons/fi"; +import { IoMdCheckmarkCircleOutline } from "react-icons/io"; +import { IoDocumentTextOutline } from "react-icons/io5"; +import { MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineDoubleArrow } from "react-icons/md"; +import { RiThumbUpLine } from "react-icons/ri"; +import { RxCrossCircled } from "react-icons/rx"; +import { TiEdit } from "react-icons/ti"; +import { toast, ToastContainer } from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { + const user = await requestUser(req, res); + if (!user) return redirect("/login") + + if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) + return redirect("/") + + const { id } = params as { id: string }; + + const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id); + + if (!workflow) return redirect("/approval-workflows") + + const entityWithRole = await getEntityWithRoles(workflow.entityId); + if (!entityWithRole) return redirect("/approval-workflows"); + + if (!doesEntityAllow(user, entityWithRole, "view_workflows")) return redirect("/approval-workflows"); + + const allAssigneeIds: string[] = [ + ...new Set( + workflow.steps + .map(step => step.assignees) + .flat() + ) + ]; + + return { + props: serialize({ + user, + initialWorkflow: workflow, + id, + workflowAssignees: await getSpecificUsers(allAssigneeIds), + workflowRequester: await getUser(workflow.requester), + }), + }; +}, sessionOptions); + +interface Props { + user: User, + initialWorkflow: ApprovalWorkflow, + id: string, + workflowAssignees: User[], + workflowRequester: User, +} + +export default function Home({ user, initialWorkflow, id, workflowAssignees, workflowRequester }: Props) { + + const { workflow, reload, isLoading } = useApprovalWorkflow(id); + + const currentWorkflow = workflow || initialWorkflow; + + let currentStepIndex = currentWorkflow.steps.findIndex(step => !step.completed || step.rejected); + if (currentStepIndex === -1) + currentStepIndex = currentWorkflow.steps.length - 1; + + const [selectedStepIndex, setSelectedStepIndex] = useState(currentStepIndex); + const [selectedStep, setSelectedStep] = useState(currentWorkflow.steps[selectedStepIndex]); + const [isPanelOpen, setIsPanelOpen] = useState(true); + const [isAccordionOpen, setIsAccordionOpen] = useState(false); + const [comments, setComments] = useState(selectedStep.comments || ""); + const [viewExamIsLoading, setViewExamIsLoading] = useState(false); + const [editExamIsLoading, setEditExamIsLoading] = useState(false); + + const router = useRouter(); + + const handleStepClick = (index: number, stepInfo: WorkflowStep) => { + setSelectedStep(stepInfo); + setSelectedStepIndex(index); + setComments(stepInfo.comments || ""); + setIsPanelOpen(true); + }; + + const handleSaveComments = () => { + const updatedWorkflow: ApprovalWorkflow = { + ...currentWorkflow, + steps: currentWorkflow.steps.map((step, index) => + index === selectedStepIndex ? + { + ...step, + comments: comments, + } + : step + ) + }; + + axios + .put(`/api/approval-workflows/${id}`, updatedWorkflow) + .then(() => { + toast.success("Comments saved successfully."); + reload(); + }) + .catch((reason) => { + if (reason.response.status === 401) { + toast.error("Not logged in!"); + } else if (reason.response.status === 403) { + toast.error("You do not have permission to approve this step!"); + } else { + toast.error("Something went wrong, please try again later."); + } + console.log("Submitted Values:", updatedWorkflow); + return; + }) + }; + + const handleApproveStep = () => { + const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length); + if (isLastStep) { + if (!confirm(`Are you sure you want to approve the last step? Doing so will approve the exam.`)) return; + } + + const updatedWorkflow: ApprovalWorkflow = { + ...currentWorkflow, + status: selectedStepIndex === currentWorkflow.steps.length - 1 ? "approved" : "pending", + steps: currentWorkflow.steps.map((step, index) => + index === selectedStepIndex ? + { + ...step, + completed: true, + completedBy: user.id, + completedDate: Date.now(), + } + : step + ) + }; + + axios + .put(`/api/approval-workflows/${id}`, updatedWorkflow) + .then(() => { + toast.success("Step approved successfully."); + reload(); + }) + .catch((reason) => { + if (reason.response.status === 401) { + toast.error("Not logged in!"); + } else if (reason.response.status === 403) { + toast.error("You do not have permission to approve this step!"); + } else { + toast.error("Something went wrong, please try again later."); + } + console.log("Submitted Values:", updatedWorkflow); + return; + }) + + if (isLastStep) { + setIsPanelOpen(false); + const examModule = currentWorkflow.modules[0]; + const examId = currentWorkflow.examId; + + axios + .patch(`/api/exam/${examModule}/${examId}`, { isDiagnostic: false }) + .then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`)) + .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); + } else { + handleStepClick(selectedStepIndex + 1, currentWorkflow.steps[selectedStepIndex + 1]); + } + }; + + const handleRejectStep = () => { + if (!confirm(`Are you sure you want to reject this step? Doing so will terminate this approval workflow.`)) return; + + const updatedWorkflow: ApprovalWorkflow = { + ...currentWorkflow, + status: "rejected", + steps: currentWorkflow.steps.map((step, index) => + index === selectedStepIndex ? + { + ...step, + completed: true, + completedBy: user.id, + completedDate: Date.now(), + rejected: true, + } + : step + ) + }; + + axios + .put(`/api/approval-workflows/${id}`, updatedWorkflow) + .then(() => { + toast.success("Step rejected successfully."); + reload(); + }) + .catch((reason) => { + if (reason.response.status === 401) { + toast.error("Not logged in!"); + } else if (reason.response.status === 403) { + toast.error("You do not have permission to approve this step!"); + } else { + toast.error("Something went wrong, please try again later."); + } + console.log("Submitted Values:", updatedWorkflow); + return; + }) + }; + + const dispatch = useExamStore((store) => store.dispatch); + const handleViewExam = async () => { + setViewExamIsLoading(true); + const examModule = currentWorkflow.modules[0]; + const examId = currentWorkflow.examId; + + if (examModule && examId) { + const exam = await getExamById(examModule, 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" } + ); + setViewExamIsLoading(false); + return; + } + dispatch({ + type: "INIT_EXAM", + payload: { exams: [exam], modules: [examModule] }, + }); + router.push("/exam"); + } + } + + const handleEditExam = () => { + setEditExamIsLoading(true); + const examModule = currentWorkflow.modules[0]; + const examId = currentWorkflow.examId; + + router.push(`/generation?id=${examId}&module=${examModule}`); + } + + return ( + <> + + Workflow | EnCoach + + + + + + +
+ + + +

{currentWorkflow.name}

+
+ +
+
+ + + +
+
+ + + +
+ {currentWorkflow.steps.find((step) => !step.completed) === undefined && + + } +
+ +
+ {currentWorkflow.steps.map((step, index) => ( + handleStepClick(index, step)} + /> + ))} +
+ + {/* Side panel */} + + +
+ {isPanelOpen && selectedStep && ( + +
+
+

Step {selectedStepIndex + 1}

+
+ +
+
+ +
+ +
+
+ {selectedStep.stepType === "approval-by" ? ( + <> + + Approval Step + + ) : ( + <> + + Form Intake Step + + ) + } +
+ + {selectedStep.completed ? ( +
+ {selectedStep.rejected ? "Rejected" : "Approved"} on {new Date(selectedStep.completedDate!).toLocaleString("en-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).replace(", ", " at ")} +
+

{selectedStep.rejected ? "Rejected" : "Approved"} by:

+ {(() => { + const assignee = workflowAssignees.find( + (assignee) => assignee.id === selectedStep.completedBy + ); + return assignee ? ( + + ) : ( + "Unknown" + ); + })()} +
+

No additional actions are required.

+
+ + ) : ( +
+ One assignee is required to sign off to complete this step: +
+ {workflowAssignees.filter(user => selectedStep.assignees.includes(user.id)).map(user => ( + + + + ))} +
+
+ )} + + {selectedStepIndex === currentStepIndex && !selectedStep.completed && !selectedStep.rejected && +
+ + +
+ } + +
+ + {/* Accordion for Exam Changes */} +
+
setIsAccordionOpen((prev) => !prev)} + > +

+ Changes ({currentWorkflow.steps[selectedStepIndex].examChanges?.length || "0"}) +

+ {isAccordionOpen ? ( + + ) : ( + + )} +
+ + {isAccordionOpen && ( + +
+ {currentWorkflow.steps[selectedStepIndex].examChanges?.length ? ( + currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => ( +

+ {change} +

+ )) + ) : ( +

No changes made so far.

+ )} +
+
+ )} +
+
+ +
+ +