From ae9a49681e838324f7859bb6b84969529c9ce691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Marques=20Lima?= Date: Mon, 20 Jan 2025 02:52:39 +0000 Subject: [PATCH] ENCOA-314 : - Implemented Async Select - Changed Stats Page User fetching to use Async Select and only fetch the User data when it needs - Changed Record Filter to use Async Select - Changed useTicketListener to only fetch needed data - Added Sort/Projection to remove unnecessary data processing. - Removed some unnecessary data processing. --- src/components/Low/AsyncSelect.tsx | 117 ++ src/components/Medium/RecordFilter.tsx | 327 ++-- src/exams/Selection.tsx | 706 +++++---- src/hooks/useFilterRecordsByUser.tsx | 20 +- src/hooks/usePermissions.tsx | 3 +- src/hooks/useTicketsListener.tsx | 26 +- src/hooks/useUserData.tsx | 27 + src/hooks/useUsersSelect.tsx | 99 ++ src/pages/api/sessions/index.ts | 2 +- src/pages/api/stats/user/[user].ts | 5 +- src/pages/api/tickets/assignedToUser/index.ts | 39 + src/pages/api/users/search.ts | 39 + src/pages/record.tsx | 17 +- src/pages/stats.tsx | 1408 ++++++++++------- src/pages/training/index.tsx | 2 +- src/utils/entities.be.ts | 4 +- src/utils/users.be.ts | 58 +- 17 files changed, 1856 insertions(+), 1043 deletions(-) create mode 100644 src/components/Low/AsyncSelect.tsx create mode 100644 src/hooks/useUserData.tsx create mode 100644 src/hooks/useUsersSelect.tsx create mode 100644 src/pages/api/tickets/assignedToUser/index.ts create mode 100644 src/pages/api/users/search.ts diff --git a/src/components/Low/AsyncSelect.tsx b/src/components/Low/AsyncSelect.tsx new file mode 100644 index 00000000..b125b2ad --- /dev/null +++ b/src/components/Low/AsyncSelect.tsx @@ -0,0 +1,117 @@ +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(); + + useEffect(() => { + if (document) setTarget(document.body); + }, []); + + return ( +
+ {label && ( + + )} + "Loading..."} + onInputChange={(inputValue) => { + loadOptions(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/exams/Selection.tsx b/src/exams/Selection.tsx index 6a39ff0a..64f9f615 100644 --- a/src/exams/Selection.tsx +++ b/src/exams/Selection.tsx @@ -1,310 +1,440 @@ /* eslint-disable @next/next/no-img-element */ -import {useMemo, useState} from "react"; -import {Module} from "@/interfaces"; +import { useMemo, useState } from "react"; +import { Module } from "@/interfaces"; import clsx from "clsx"; -import {Stat, User} from "@/interfaces/user"; -import ProgressBar from "@/components/Low/ProgressBar"; -import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; -import {totalExamsByModule} from "@/utils/stats"; +import { Stat, User } from "@/interfaces/user"; +import { + BsArrowRepeat, + BsBook, + BsCheck, + BsCheckCircle, + BsClipboard, + BsHeadphones, + BsMegaphone, + BsPen, + BsXCircle, +} from "react-icons/bs"; +import { totalExamsByModule } from "@/utils/stats"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import Button from "@/components/Low/Button"; -import {calculateAverageLevel} from "@/utils/score"; -import {sortByModuleName} from "@/utils/moduleUtils"; -import {capitalize} from "lodash"; +import { sortByModuleName } from "@/utils/moduleUtils"; +import { capitalize } from "lodash"; import ProfileSummary from "@/components/ProfileSummary"; -import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam"; -import useSessions, {Session} from "@/hooks/useSessions"; +import { Variant } from "@/interfaces/exam"; +import useSessions, { Session } from "@/hooks/useSessions"; import SessionCard from "@/components/Medium/SessionCard"; import useExamStore from "@/stores/exam"; import moment from "moment"; interface Props { - user: User; - page: "exercises" | "exams"; - onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => 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: stats } = useFilterRecordsByUser(user?.id); + 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: 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.", + }, + ]} + /> + )} -
- 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/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/useTicketsListener.tsx b/src/hooks/useTicketsListener.tsx index 520ee947..a3babb1a 100644 --- a/src/hooks/useTicketsListener.tsx +++ b/src/hooks/useTicketsListener.tsx @@ -1,22 +1,28 @@ -import React from "react"; -import useTickets from "./useTickets"; +import { useState, useEffect } from "react"; +import axios from "axios"; const useTicketsListener = (userId?: string) => { - const { tickets, reload } = useTickets(); + const [assignedTickets, setAssignedTickets] = useState([]); - React.useEffect(() => { + const getData = () => { + axios + .get("/api/tickets/assignedToUser") + .then((response) => setAssignedTickets(response.data)); + }; + + useEffect(() => { + getData(); + }, []); + + useEffect(() => { const intervalId = setInterval(() => { - reload(); + getData(); }, 60 * 1000); return () => clearInterval(intervalId); - }, [reload]); + }, [assignedTickets]); 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/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/user/[user].ts b/src/pages/api/stats/user/[user].ts index 1f8352d6..35aa9bf2 100644 --- a/src/pages/api/stats/user/[user].ts +++ b/src/pages/api/stats/user/[user].ts @@ -15,7 +15,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } const {user} = req.query; - const snapshot = await db.collection("stats").find({ user: user }).toArray(); + const snapshot = await db.collection("stats").aggregate([ + { $match: { user: user } }, + { $sort: { "date": 1 } } + ]).toArray(); 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..8db00dbc --- /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 }).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/record.tsx b/src/pages/record.tsx index 6366a742..d019272a 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -21,11 +21,9 @@ import useTrainingContentStore from "@/stores/trainingContentStore"; import { Assignment } from "@/interfaces/results"; import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; -import useGradingSystem from "@/hooks/useGrading"; import { findBy, mapBy, redirect, serialize } from "@/utils"; import { getEntitiesWithRoles } from "@/utils/entities.be"; import { checkAccess } from "@/utils/permissions"; -import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be"; import { Grading } from "@/interfaces"; import { EntityWithRoles } from "@/interfaces/entity"; @@ -40,14 +38,16 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { if (shouldRedirectHome(user)) return redirect("/") const entityIDs = mapBy(user.entities, 'id') + const isAdmin = checkAccess(user, ["admin", "developer"]) const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) - const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id'))) - const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id'))) - const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id')) + const entitiesIds = mapBy(entities, 'id') + const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds)) + const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds)) + const gradingSystems = await getGradingSystemByEntities(entitiesIds) return { - props: serialize({ user, users, assignments, entities, gradingSystems }), + props: serialize({ user, users, assignments, entities, gradingSystems,isAdmin }), }; }, sessionOptions); @@ -59,11 +59,12 @@ interface Props { assignments: Assignment[]; entities: EntityWithRoles[] gradingSystems: Grading[] + isAdmin:boolean } const MAX_TRAINING_EXAMS = 10; -export default function History({ user, users, assignments, entities, gradingSystems }: Props) { +export default function History({ user, users, assignments, entities, gradingSystems,isAdmin }: Props) { const router = useRouter(); const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ state.selectedUser, @@ -193,7 +194,7 @@ export default function History({ user, users, assignments, entities, gradingSys {user && ( - + {training && (
diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx index 6c402baa..51dac7a0 100644 --- a/src/pages/stats.tsx +++ b/src/pages/stats.tsx @@ -1,643 +1,895 @@ /* eslint-disable @next/next/no-img-element */ import Head from "next/head"; -import { BsArrowClockwise, BsChevronLeft, BsChevronRight, BsFileEarmarkText, BsPencil, BsStar } from "react-icons/bs"; -import { LinearScale, Chart as ChartJS, CategoryScale, PointElement, LineElement, Legend, Tooltip, LineController } from "chart.js"; +import { + BsArrowClockwise, + BsChevronLeft, + BsChevronRight, + BsFileEarmarkText, + BsPencil, + BsStar, +} from "react-icons/bs"; +import { + LinearScale, + Chart as ChartJS, + CategoryScale, + PointElement, + LineElement, + Legend, + Tooltip, + LineController, +} from "chart.js"; import { withIronSessionSsr } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import { useEffect, useMemo, useState } from "react"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; -import { averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate } from "@/utils/stats"; -import useUser from "@/hooks/useUser"; +import { + averageScore, + groupBySession, + groupByModule, + timestampToMoment, +} from "@/utils/stats"; import { ToastContainer } from "react-toastify"; -import { capitalize, Dictionary } from "lodash"; +import { capitalize } from "lodash"; import { Module } from "@/interfaces"; import ProgressBar from "@/components/Low/ProgressBar"; import Layout from "@/components/High/Layout"; -import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; -import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils"; +import { calculateBandScore } from "@/utils/score"; +import { + countExamModules, + countFullExams, + MODULE_ARRAY, + sortByModule, +} from "@/utils/moduleUtils"; import { Chart } from "react-chartjs-2"; -import useUsers from "@/hooks/useUsers"; -import useGroups from "@/hooks/useGroups"; import DatePicker from "react-datepicker"; import { shouldRedirectHome } from "@/utils/navigation.disabled"; import ProfileSummary from "@/components/ProfileSummary"; import moment from "moment"; -import { Group, Stat, User } from "@/interfaces/user"; +import { Stat, User } from "@/interfaces/user"; import { Divider } from "primereact/divider"; import Badge from "@/components/Low/Badge"; -import { filterBy, mapBy, redirect, serialize } from "@/utils"; -import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { mapBy, redirect, serialize } from "@/utils"; +import { getEntities } from "@/utils/entities.be"; import { checkAccess } from "@/utils/permissions"; -import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { EntityWithRoles } from "@/interfaces/entity"; -import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; import Select from "@/components/Low/Select"; import { requestUser } from "@/utils/api"; +import useUserData from "../hooks/useUserData"; +import useUsersSelect from "../hooks/useUsersSelect"; +import AsyncSelect from "../components/Low/AsyncSelect"; -ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip); +ChartJS.register( + LinearScale, + CategoryScale, + PointElement, + LineElement, + LineController, + Legend, + Tooltip +); const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"]; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { - const user = await requestUser(req, res) - if (!user) return redirect("/login") + const user = await requestUser(req, res); + if (!user) return redirect("/login"); - if (shouldRedirectHome(user)) return redirect("/") + if (shouldRedirectHome(user)) return redirect("/"); - const entityIDs = mapBy(user.entities, 'id') - const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) - const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id'))) - const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id'))) + const entityIDs = mapBy(user.entities, "id"); + const isAdmin = checkAccess(user, ["admin", "developer"]); - return { - props: serialize({ user, entities, users, groups }), - }; + const entities = await getEntities(isAdmin ? undefined : entityIDs, { + id: 1, + label: 1, + }); + + return { + props: serialize({ user, entities, isAdmin }), + }; }, sessionOptions); interface Props { - user: User - users: User[] - entities: EntityWithRoles[] - groups: Group[] + user: User; + entities: EntityWithRoles[]; + isAdmin: boolean; } -export default function Stats({ user, entities, users, groups }: Props) { - const [statsUserId, setStatsUserId] = useState(user.id); - const [startDate, setStartDate] = useState(moment(new Date()).subtract(1, "weeks").toDate()); - const [endDate, setEndDate] = useState(new Date()); - const [initialStatDate, setInitialStatDate] = useState(); - const [selectedEntity, setSelectedEntity] = useState() +export default function Stats({ user, entities, isAdmin }: Props) { + const [statsUserId, setStatsUserId] = useState(user.id); - const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState(new Date()); - const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState(new Date()); + const { userData } = useUserData({ userId: statsUserId }); - const [dailyScoreDate, setDailyScoreDate] = useState(new Date()); - const [intervalDates, setIntervalDates] = useState([]); + const [startDate, setStartDate] = useState( + moment(new Date()).subtract(1, "weeks").toDate() + ); - const { data: stats } = useFilterRecordsByUser(statsUserId, !statsUserId); + const [endDate, setEndDate] = useState(new Date()); - const students = useMemo(() => - filterBy(users, 'type', 'student').filter(x => !selectedEntity ? true : mapBy(x.entities, 'id').includes((selectedEntity))), - [users, selectedEntity] - ) + const [selectedEntity, setSelectedEntity] = useState(); - useEffect(() => { - setInitialStatDate( - stats - .filter((s) => s.date) - .sort((a, b) => timestampToMoment(a).diff(timestampToMoment(b))) - .map(timestampToMoment) - .shift() - ?.toDate(), - ); - }, [stats]); + const entitiesToSearch = useMemo(() => { + if(selectedEntity) return [selectedEntity] + if (isAdmin) return undefined; + return mapBy(entities, "id"); + }, [entities, isAdmin, selectedEntity]); - const calculateModuleScore = (stats: Stat[]) => { - const moduleStats = groupByModule(stats); - return Object.keys(moduleStats).map((y) => { - const correct = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.correct, 0); - const total = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.total, 0); + const { + users: students, + isLoading, + onScrollLoadMoreOptions, + loadOptions, + } = useUsersSelect({ + type: "student", + size: 50, + orderBy: "name", + direction: "asc", + entities: entitiesToSearch, + }); - return { - module: y as Module, - score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"), - }; - }); - }; + const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = + useState(new Date()); - const calculateModularScorePerSession = (stats: Stat[], module: Module) => { - const groupedBySession = groupBySession(stats); - const sessionAverage = Object.keys(groupedBySession).map((x: string) => { - const session = groupedBySession[x]; - const moduleStats = groupByModule(session); - if (!Object.keys(moduleStats).includes(module)) return null; - const correct = moduleStats[module].reduce((acc, curr) => acc + curr.score.correct, 0); - const total = moduleStats[module].reduce((acc, curr) => acc + curr.score.total, 0); + const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = + useState(new Date()); - return calculateBandScore(correct, total, module, user?.focus || "academic"); - }); + const [dailyScoreDate, setDailyScoreDate] = useState(new Date()); - return sessionAverage; - }; + const [intervalDates, setIntervalDates] = useState([]); - const getListOfDateInInterval = (start: Date, end: Date) => { - let currentDate = moment(start); - const dates = [currentDate.toDate()]; - while (moment(end).diff(currentDate, "days") > 0) { - currentDate = currentDate.add(1, "days"); - dates.push(currentDate.toDate()); - } + const { data: stats } = useFilterRecordsByUser( + statsUserId, + !statsUserId + ); - return dates; - }; + const initialStatDate = useMemo( + () => (stats[0] ? timestampToMoment(stats[0]).toDate() : null), + [stats] + ); - useEffect(() => { - if (startDate && endDate) { - setIntervalDates(getListOfDateInInterval(startDate, endDate)); - } - }, [startDate, endDate]); + const calculateModuleScore = (stats: Stat[]) => { + const moduleStats = groupByModule(stats); + return Object.keys(moduleStats).map((y) => { + const correct = moduleStats[y].reduce( + (accumulator, current) => accumulator + current.score.correct, + 0 + ); + const total = moduleStats[y].reduce( + (accumulator, current) => accumulator + current.score.total, + 0 + ); - const calculateTotalScore = (stats: Stat[], divisionFactor: number) => { - const moduleScores = calculateModuleScore(stats); - return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / divisionFactor; - }; + return { + module: y as Module, + score: calculateBandScore( + correct, + total, + y as Module, + user?.focus || "academic" + ), + }; + }); + }; - const calculateScorePerModule = (stats: Stat[], module: Module) => { - const moduleScores = calculateModuleScore(stats); - return moduleScores.find((x) => x.module === module)?.score || -1; - }; + const calculateModularScorePerSession = (stats: Stat[], module: Module) => { + const groupedBySession = groupBySession(stats); + const sessionAverage = Object.keys(groupedBySession).map((x: string) => { + const session = groupedBySession[x]; + const moduleStats = groupByModule(session); + if (!Object.keys(moduleStats).includes(module)) return null; + const correct = moduleStats[module].reduce( + (acc, curr) => acc + curr.score.correct, + 0 + ); + const total = moduleStats[module].reduce( + (acc, curr) => acc + curr.score.total, + 0 + ); - return ( - <> - - Stats | EnCoach - - - - - - {user && ( - - x.id === statsUserId) || user} - items={[ - { - icon: , - value: countFullExams(stats), - label: "Exams", - tooltip: "Number of all conducted completed exams", - }, - { - icon: , - value: countExamModules(stats), - label: "Modules", - tooltip: "Number of all exam modules performed including Level Test", - }, - { - icon: , - value: `${stats.length > 0 ? averageScore(stats) : 0}%`, - label: "Average Score", - tooltip: "Average success rate for questions responded", - }, - ]} - /> + return calculateBandScore( + correct, + total, + module, + user?.focus || "academic" + ); + }); -
-
- {["corporate", "teacher", "mastercorporate", "developer", "admin"].includes(user.type) && ( - <> - ({ value: x.id, label: `${x.name} - ${x.email}` }))} - defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} - onChange={(value) => setStatsUserId(value?.value || user.id)} - /> - - )} -
+ return sessionAverage; + }; - {stats.length > 0 && ( - <> -
- {/* Overall Level per Month */} -
-
- Overall Level per Month -
- {monthlyOverallScoreDate && ( - - )} - - {monthlyOverallScoreDate && ( - - )} - -
-
-
- {[...Array(31).keys()].map((day) => { - const date = moment( - `${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1 - }/${moment(monthlyOverallScoreDate).get("year")}`, - "DD/MM/yyyy", - ); + const getListOfDateInInterval = (start: Date, end: Date) => { + let currentDate = moment(start); + const dates = [currentDate.toDate()]; + while (moment(end).diff(currentDate, "days") > 0) { + currentDate = currentDate.add(1, "days"); + dates.push(currentDate.toDate()); + } - return date.isValid() && date.isSameOrBefore(moment()) ? ( -
- - Day {(day + 1).toString().padStart(2, "0")} - - - Level{" "} - {calculateTotalScore( - stats.filter((s) => timestampToMoment(s).isBefore(date)), - 5, - ).toFixed(1)} - -
- ) : null; - })} -
-
+ return dates; + }; - {/* Overall Level per Month Graph */} -
-
- Overall Level per Month -
- {monthlyOverallScoreDate && ( - - )} - - {monthlyOverallScoreDate && ( - - )} - -
-
- { - const date = moment( - `${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1 - }/${moment(monthlyOverallScoreDate).get("year")}`, - "DD/MM/yyyy", - ); - return date.isValid() ? (day + 1).toString().padStart(2, "0") : undefined; - }) - .filter((x) => !!x), - datasets: [ - { - type: "line", - label: "Total", - fill: false, - borderColor: "#6A5FB1", - backgroundColor: "#7872BF", - borderWidth: 2, - spanGaps: true, - data: [...Array(31).keys()] - .map((day) => { - const date = moment( - `${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1 - }/${moment(monthlyOverallScoreDate).get("year")}`, - "DD/MM/yyyy", - ); + useEffect(() => { + if (startDate && endDate) { + setIntervalDates(getListOfDateInInterval(startDate, endDate)); + } + }, [startDate, endDate]); - return date.isValid() - ? calculateTotalScore( - stats.filter((s) => timestampToMoment(s).isBefore(date)), - 5, - ).toFixed(1) - : undefined; - }) - .filter((x) => !!x), - }, - ], - }} - /> -
+ const calculateTotalScore = (stats: Stat[], divisionFactor: number) => { + const moduleScores = calculateModuleScore(stats); + return ( + moduleScores.reduce((acc, curr) => acc + curr.score, 0) / divisionFactor + ); + }; - {/* Module Level per Day */} -
-
- Module Level per Day -
- {monthlyModuleScoreDate && ( - - )} - - {monthlyModuleScoreDate && ( - - )} - -
-
-
- {calculateModuleScore(stats.filter((s) => timestampToMoment(s).isBefore(moment(monthlyModuleScoreDate)))) - .sort(sortByModule) - .map(({ module, score }) => ( -
-
- - {score} of 9 - - {capitalize(module)} -
- -
- ))} -
-
-
+ const calculateScorePerModule = (stats: Stat[], module: Module) => { + const moduleScores = calculateModuleScore(stats); + return moduleScores.find((x) => x.module === module)?.score || -1; + }; - + return ( + <> + + Stats | EnCoach + + + + + + {user && ( + + + ), + value: countFullExams(stats), + label: "Exams", + tooltip: "Number of all conducted completed exams", + }, + { + icon: ( + + ), + value: countExamModules(stats), + label: "Modules", + tooltip: + "Number of all exam modules performed including Level Test", + }, + { + icon: ( + + ), + value: `${stats.length > 0 ? averageScore(stats) : 0}%`, + label: "Average Score", + tooltip: "Average success rate for questions responded", + }, + ]} + /> -
- {/* Module Level per Exam */} -
-
- Module Level per Exam -
- {dailyScoreDate && ( - - )} - - {dailyScoreDate && ( - - )} - -
-
+
+
+ {[ + "corporate", + "teacher", + "mastercorporate", + "developer", + "admin", + ].includes(user.type) && ( + <> +