diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index 909860c9..f8419b7d 100644 --- a/src/components/Exercises/FillBlanks/index.tsx +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -1,7 +1,7 @@ import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import reactStringReplace from "react-string-replace"; import { CommonProps } from ".."; import Button from "../../Low/Button"; @@ -45,7 +45,7 @@ const FillBlanks: React.FC = ({ let correctWords: any; - if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { + if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; } @@ -55,10 +55,11 @@ const FillBlanks: React.FC = ({ const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; if (!solution) return false; const option = correctWords!.find((w: any) => { + console.log(w); if (typeof w === "string") { return w.toLowerCase() === x.solution.toLowerCase(); } else if ('letter' in w) { - return w.word.toLowerCase() === x.solution.toLowerCase(); + return w.letter.toLowerCase() === x.solution.toLowerCase(); } else { return w.id.toString() === x.id.toString(); } @@ -77,7 +78,7 @@ const FillBlanks: React.FC = ({ const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; return { total, correct, missing }; }; - const renderLines = (line: string) => { + const renderLines = useCallback((line: string) => { return (
{reactStringReplace(line, /({{\d+}})/g, (match) => { @@ -121,21 +122,34 @@ const FillBlanks: React.FC = ({ })}
); - }; + }, [variant, words, setCurrentMCSelection, answers]); + + const memoizedLines = useMemo(() => { + return text.split("\\n").map((line, index) => ( +

+ {renderLines(line)} +
+

+ )); + }, [text, variant, renderLines]); + const onSelection = (questionID: string, value: string) => { setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]); } useEffect(() => { - setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); + //if (variant === "mc") { + console.log(answers); + setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); + //} // eslint-disable-next-line react-hooks/exhaustive-deps }, [answers]) return ( <>
- {false && + {variant !== "mc" && {prompt.split("\\n").map((line, index) => ( {line} @@ -144,12 +158,7 @@ const FillBlanks: React.FC = ({ ))} } - {text.split("\\n").map((line, index) => ( -

- {renderLines(line)} -
-

- ))} + {memoizedLines}
{variant === "mc" && typeCheckWordsMC(words) ? ( <> diff --git a/src/components/Medium/RecordFilter.tsx b/src/components/Medium/RecordFilter.tsx new file mode 100644 index 00000000..c70882c7 --- /dev/null +++ b/src/components/Medium/RecordFilter.tsx @@ -0,0 +1,206 @@ +import { User } from "@/interfaces/user"; +import { checkAccess } from "@/utils/permissions"; +import Select from "../Low/Select"; +import { ReactNode, useEffect, useState } from "react"; +import clsx from "clsx"; +import useUsers from "@/hooks/useUsers"; +import useGroups from "@/hooks/useGroups"; +import useRecordStore from "@/stores/recordStore"; + + +type TimeFilter = "months" | "weeks" | "days"; +type Filter = TimeFilter | "assignments" | undefined; + +interface Props { + user: User; + filterState: { + filter: Filter, + setFilter: React.Dispatch> + }, + assignments?: boolean; + children?: ReactNode +} + +const defaultSelectableCorporate = { + value: "", + label: "All", +}; + +const RecordFilter: React.FC = ({ + user, + filterState, + assignments = true, + children +}) => { + const { filter, setFilter } = filterState; + + const [statsUserId, setStatsUserId] = useRecordStore((state) => [ + state.selectedUser, + state.setSelectedUser + ]); + + const { users } = useUsers(); + const { groups: allGroups } = useGroups({}); + const { groups } = useGroups({ admin: user?.id, userType: user?.type }); + + const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { + setFilter((prev) => (prev === value ? undefined : value)); + }; + + const selectableCorporates = [ + defaultSelectableCorporate, + ...users + .filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)) + .filter((x) => x.type === "corporate") + .map((x) => ({ + value: x.id, + label: `${x.name} - ${x.email}`, + })), + ]; + + const [selectedCorporate, setSelectedCorporate] = useState(defaultSelectableCorporate.value); + + const getUsersList = (): User[] => { + if (selectedCorporate) { + const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate); + const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants); + + const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[]; + return userListWithUsers.filter((x) => x); + } + + return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)); + }; + + const corporateFilteredUserList = getUsersList(); + + const getSelectedUser = () => { + if (selectedCorporate) { + const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId); + return userInCorporate || corporateFilteredUserList[0]; + } + + return users.find((x) => x.id === statsUserId) || user; + }; + + const selectedUser = getSelectedUser(); + const selectedUserSelectValue = selectedUser + ? { + value: selectedUser.id, + label: `${selectedUser.name} - ${selectedUser.email}`, + } + : { + value: "", + label: "", + }; + + return ( +
+
+ {checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && ( + <> + + + + + + groups.flatMap((y) => y.participants).includes(x.id)) + .map((x) => ({ + value: x.id, + label: `${x.name} - ${x.email}`, + }))} + value={selectedUserSelectValue} + 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 && ( + + )} + + + +
+
+ ); +} + +export default RecordFilter; \ No newline at end of file diff --git a/src/components/StatGridItem.tsx b/src/components/Medium/StatGridItem.tsx similarity index 94% rename from src/components/StatGridItem.tsx rename to src/components/Medium/StatGridItem.tsx index 72cd996f..7d0edfcd 100644 --- a/src/components/StatGridItem.tsx +++ b/src/components/Medium/StatGridItem.tsx @@ -4,17 +4,17 @@ import clsx from "clsx"; import {Stat, User} from "@/interfaces/user"; import {Module} from "@/interfaces"; import ai_usage from "@/utils/ai.detection"; -import {calculateBandScore} from "@/utils/score"; -import moment from "moment"; -import {Assignment} from "@/interfaces/results"; -import {uuidv4} from "@firebase/util"; -import {useRouter} from "next/router"; -import {uniqBy} from "lodash"; -import {sortByModule} from "@/utils/moduleUtils"; -import {convertToUserSolutions} from "@/utils/stats"; -import {getExamById} from "@/utils/exams"; -import {Exam, UserSolution} from "@/interfaces/exam"; -import ModuleBadge from "./ModuleBadge"; +import { calculateBandScore } from "@/utils/score"; +import moment from 'moment'; +import { Assignment } from '@/interfaces/results'; +import { uuidv4 } from "@firebase/util"; +import { useRouter } from "next/router"; +import { uniqBy } from "lodash"; +import { sortByModule } from "@/utils/moduleUtils"; +import { convertToUserSolutions } from "@/utils/stats"; +import { getExamById } from "@/utils/exams"; +import { Exam, UserSolution } from '@/interfaces/exam'; +import ModuleBadge from '../ModuleBadge'; const formatTimestamp = (timestamp: string | number) => { const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp; diff --git a/src/components/Solutions/FillBlanks.tsx b/src/components/Solutions/FillBlanks.tsx index 92dee6b5..8a947473 100644 --- a/src/components/Solutions/FillBlanks.tsx +++ b/src/components/Solutions/FillBlanks.tsx @@ -34,7 +34,7 @@ export default function FillBlanksSolutions({ if (typeof w === "string") { return w.toLowerCase() === x.solution.toLowerCase(); } else if ('letter' in w) { - return w.word.toLowerCase() === x.solution.toLowerCase(); + return w.letter.toLowerCase() === x.solution.toLowerCase(); } else { return w.id.toString() === x.id.toString(); } diff --git a/src/components/TrainingContent/TrainingInterfaces.ts b/src/components/TrainingContent/TrainingInterfaces.ts index 549531d2..0d95559f 100644 --- a/src/components/TrainingContent/TrainingInterfaces.ts +++ b/src/components/TrainingContent/TrainingInterfaces.ts @@ -3,6 +3,7 @@ import { Stat } from "@/interfaces/user"; export interface ITrainingContent { id: string; created_at: number; + user: string; exams: { id: string; date: number; diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx index c5d1c273..e0e6bb45 100644 --- a/src/components/UserCard.tsx +++ b/src/components/UserCard.tsx @@ -1,5 +1,5 @@ -import useStats from "@/hooks/useStats"; -import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type} from "@/interfaces/user"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; +import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type, Stat} from "@/interfaces/user"; import {groupBySession, averageScore} from "@/utils/stats"; import {RadioGroup} from "@headlessui/react"; import axios from "axios"; @@ -122,7 +122,7 @@ const UserCard = ({ const [commissionValue, setCommission] = useState( user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.commission : undefined, ); - const {stats} = useStats(user.id); + const {data: stats} = useFilterRecordsByUser(user.id); const {users} = useUsers(); const {codes} = useCodes(user.id); const {permissions} = usePermissions(loggedInUser.id); diff --git a/src/dashboards/Admin.tsx b/src/dashboards/Admin.tsx index 65f15ed1..7c4942da 100644 --- a/src/dashboards/Admin.tsx +++ b/src/dashboards/Admin.tsx @@ -1,8 +1,8 @@ /* eslint-disable @next/next/no-img-element */ import Modal from "@/components/Modal"; -import useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useUsers from "@/hooks/useUsers"; -import {User} from "@/interfaces/user"; +import {Stat, User} from "@/interfaces/user"; import UserList from "@/pages/(admin)/Lists/UserList"; import {dateSorter} from "@/utils"; import moment from "moment"; @@ -36,7 +36,7 @@ export default function AdminDashboard({user}: Props) { const [selectedUser, setSelectedUser] = useState(); const [showModal, setShowModal] = useState(false); - const {stats} = useStats(user.id); + const {data: stats} = useFilterRecordsByUser(user.id); const {users, reload} = useUsers(); const {groups} = useGroups({}); const {pending, done} = usePaymentStatusUsers(); diff --git a/src/dashboards/Agent.tsx b/src/dashboards/Agent.tsx index 11028894..ce5ff763 100644 --- a/src/dashboards/Agent.tsx +++ b/src/dashboards/Agent.tsx @@ -1,8 +1,8 @@ /* eslint-disable @next/next/no-img-element */ import Modal from "@/components/Modal"; -import useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useUsers from "@/hooks/useUsers"; -import {User} from "@/interfaces/user"; +import {Stat, User} from "@/interfaces/user"; import UserList from "@/pages/(admin)/Lists/UserList"; import {dateSorter} from "@/utils"; import moment from "moment"; @@ -23,7 +23,7 @@ export default function AgentDashboard({user}: Props) { const [selectedUser, setSelectedUser] = useState(); const [showModal, setShowModal] = useState(false); - const {stats} = useStats(); + const {data: stats} = useFilterRecordsByUser(); const {users, reload} = useUsers(); const {pending, done} = usePaymentStatusUsers(); diff --git a/src/dashboards/Corporate.tsx b/src/dashboards/Corporate.tsx index bd759264..2f0c0f5e 100644 --- a/src/dashboards/Corporate.tsx +++ b/src/dashboards/Corporate.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ import Modal from "@/components/Modal"; -import useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useUsers from "@/hooks/useUsers"; import { CorporateUser, Group, Stat, User } from "@/interfaces/user"; import UserList from "@/pages/(admin)/Lists/UserList"; @@ -232,7 +232,7 @@ export default function CorporateDashboard({ user }: Props) { const [selectedAssignment, setSelectedAssignment] = useState(); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); - const { stats } = useStats(); + const { data: stats } = useFilterRecordsByUser(); const { users, reload, isLoading } = useUsers(); const { codes } = useCodes(user.id); const { groups } = useGroups({ admin: user.id }); diff --git a/src/dashboards/MasterCorporate.tsx b/src/dashboards/MasterCorporate.tsx index c90659e5..9c2a0554 100644 --- a/src/dashboards/MasterCorporate.tsx +++ b/src/dashboards/MasterCorporate.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ import Modal from "@/components/Modal"; -import useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useUsers from "@/hooks/useUsers"; import { CorporateUser, @@ -435,7 +435,7 @@ export default function MasterCorporateDashboard({ user }: Props) { (Assignment & { corporate?: CorporateUser })[] >([]); - const { stats } = useStats(); + const { data: stats } = useFilterRecordsByUser(); const { users, reload } = useUsers(); const { codes } = useCodes(user.id); const { groups } = useGroups({ admin: user.id, userType: user.type }); diff --git a/src/dashboards/Student.tsx b/src/dashboards/Student.tsx index c6378253..5fd2a862 100644 --- a/src/dashboards/Student.tsx +++ b/src/dashboards/Student.tsx @@ -5,11 +5,11 @@ import ProfileSummary from "@/components/ProfileSummary"; import useAssignments from "@/hooks/useAssignments"; import useGradingSystem from "@/hooks/useGrading"; import useInvites from "@/hooks/useInvites"; -import useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useUsers from "@/hooks/useUsers"; import {Invite} from "@/interfaces/invite"; import {Assignment} from "@/interfaces/results"; -import {CorporateUser, User} from "@/interfaces/user"; +import {CorporateUser, Stat, User} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; import {getExamById} from "@/utils/exams"; import {getUserCorporate} from "@/utils/groups"; @@ -37,7 +37,7 @@ export default function StudentDashboard({user}: Props) { const {users} = useUsers(); const {gradingSystem} = useGradingSystem(); - const {stats} = useStats(user.id, !user?.id); + const {data: stats} = useFilterRecordsByUser(user.id, !user?.id); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id}); diff --git a/src/dashboards/Teacher.tsx b/src/dashboards/Teacher.tsx index 3c28eea5..0bd894d1 100644 --- a/src/dashboards/Teacher.tsx +++ b/src/dashboards/Teacher.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ import Modal from "@/components/Modal"; -import useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useUsers from "@/hooks/useUsers"; import { CorporateUser, Group, Stat, User } from "@/interfaces/user"; import UserList from "@/pages/(admin)/Lists/UserList"; @@ -68,7 +68,7 @@ export default function TeacherDashboard({ user }: Props) { const [corporateUserToShow, setCorporateUserToShow] = useState(); - const { stats } = useStats(); + const { data: stats } = useFilterRecordsByUser(); const { users, reload } = useUsers(); const { groups } = useGroups({ adminAdmins: user.id }); const { permissions } = usePermissions(user.id); diff --git a/src/exams/Selection.tsx b/src/exams/Selection.tsx index e713d80d..4f3f33a4 100644 --- a/src/exams/Selection.tsx +++ b/src/exams/Selection.tsx @@ -2,11 +2,11 @@ import {useState} from "react"; import {Module} from "@/interfaces"; import clsx from "clsx"; -import {User} from "@/interfaces/user"; +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 useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import Button from "@/components/Low/Button"; import {calculateAverageLevel} from "@/utils/score"; import {sortByModuleName} from "@/utils/moduleUtils"; @@ -30,7 +30,7 @@ export default function Selection({user, page, onStart, disableSelection = false const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [variant, setVariant] = useState("full"); - const {stats} = useStats(user?.id); + const {data: stats} = useFilterRecordsByUser(user?.id); const {sessions, isLoading, reload} = useSessions(user.id); const state = useExamStore((state) => state); diff --git a/src/hooks/useFilterRecordsByUser.tsx b/src/hooks/useFilterRecordsByUser.tsx new file mode 100644 index 00000000..908dab17 --- /dev/null +++ b/src/hooks/useFilterRecordsByUser.tsx @@ -0,0 +1,51 @@ +import axios from "axios"; +import { useEffect, useState } from "react"; + +const endpoints: Record = { + stats: "/api/stats", + training: "/api/training" +}; + +export default function useFilterRecordsByUser( + id?: string, + shouldNotQuery?: boolean, + recordType: 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 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 getData = () => { + if (shouldNotQuery) return; + + setIsLoading(true); + setIsError(false); + + axios + .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); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + getData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, shouldNotQuery, recordType, endpoint]); + + return { + data, + reload: getData, + isLoading, + isError + }; +} \ No newline at end of file diff --git a/src/hooks/useStats.tsx b/src/hooks/useStats.tsx deleted file mode 100644 index bcea4234..00000000 --- a/src/hooks/useStats.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {Stat, User} from "@/interfaces/user"; -import axios from "axios"; -import {useEffect, useState} from "react"; - -export default function useStats(id?: string, shouldNotQuery?: boolean) { - const [stats, setStats] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); - - const getData = () => { - if (shouldNotQuery) return; - - setIsLoading(true); - axios - .get(!id ? "/api/stats" : `/api/stats/user/${id}`) - .then((response) => setStats(response.data.filter((x) => (id ? x.user === id : true)))) - .finally(() => setIsLoading(false)); - }; - - useEffect(getData, [id, shouldNotQuery]); - - return {stats, reload: getData, isLoading, isError}; -} diff --git a/src/pages/api/training/user/[user].ts b/src/pages/api/training/user/[user].ts new file mode 100644 index 00000000..1226e4c9 --- /dev/null +++ b/src/pages/api/training/user/[user].ts @@ -0,0 +1,29 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {app} from "@/firebase"; +import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {user} = req.query; + const q = query(collection(db, "training"), where("user", "==", user)); + + const snapshot = await getDocs(q); + + res.status(200).json( + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })), + ); +} \ No newline at end of file diff --git a/src/pages/groups.tsx b/src/pages/groups.tsx index 6e037316..deec7b8a 100644 --- a/src/pages/groups.tsx +++ b/src/pages/groups.tsx @@ -5,7 +5,6 @@ import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMega import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {useEffect, useState} from "react"; -import useStats from "@/hooks/useStats"; import {averageScore, groupBySession, totalExams} from "@/utils/stats"; import useUser from "@/hooks/useUser"; import Diagnostic from "@/components/Diagnostic"; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 9a824ded..b0f02074 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -13,7 +13,6 @@ import { import { withIronSessionSsr } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import { useEffect, useState } from "react"; -import useStats from "@/hooks/useStats"; import { averageScore, groupBySession, totalExams } from "@/utils/stats"; import useUser from "@/hooks/useUser"; import Diagnostic from "@/components/Diagnostic"; diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 59481dfb..798655ca 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -1,30 +1,28 @@ /* eslint-disable @next/next/no-img-element */ import Head from "next/head"; -import {withIronSessionSsr} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {Stat, User} from "@/interfaces/user"; -import {useEffect, useRef, useState} from "react"; -import useStats from "@/hooks/useStats"; -import {groupByDate} from "@/utils/stats"; +import { withIronSessionSsr } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Stat, User } from "@/interfaces/user"; +import { useEffect, useRef, useState } from "react"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; +import { groupByDate } from "@/utils/stats"; import moment from "moment"; import useUsers from "@/hooks/useUsers"; import useExamStore from "@/stores/examStore"; -import {ToastContainer} from "react-toastify"; -import {useRouter} from "next/router"; +import { ToastContainer } from "react-toastify"; import Layout from "@/components/High/Layout"; import clsx from "clsx"; -import Select from "@/components/Low/Select"; -import useGroups from "@/hooks/useGroups"; -import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; import useAssignments from "@/hooks/useAssignments"; -import {uuidv4} from "@firebase/util"; -import {usePDFDownload} from "@/hooks/usePDFDownload"; +import { uuidv4 } from "@firebase/util"; +import { usePDFDownload } from "@/hooks/usePDFDownload"; import useRecordStore from "@/stores/recordStore"; +import StatsGridItem from "@/components/Medium/StatGridItem"; +import RecordFilter from "@/components/Medium/RecordFilter"; +import { useRouter } from "next/router"; import useTrainingContentStore from "@/stores/trainingContentStore"; -import StatsGridItem from "@/components/StatGridItem"; -import {checkAccess} from "@/utils/permissions"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { +export const getServerSideProps = withIronSessionSsr(({ req, res }) => { const user = req.session.user; if (!user || !user.isVerified) { @@ -46,16 +44,14 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { } return { - props: {user: req.session.user}, + props: { user: req.session.user }, }; }, sessionOptions); -const defaultSelectableCorporate = { - value: "", - label: "All", -}; +type Filter = "months" | "weeks" | "days" | "assignments" | undefined; -export default function History({user}: {user: User}) { +export default function History({ user }: { user: User }) { + const router = useRouter(); const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ state.selectedUser, state.setSelectedUser, @@ -64,14 +60,12 @@ export default function History({user}: {user: User}) { ]); // const [statsUserId, setStatsUserId] = useState(user.id); - const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); - const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); - const {assignments} = useAssignments({}); + const [groupedStats, setGroupedStats] = useState<{ [key: string]: Stat[] }>(); + const [filter, setFilter] = useState(); + const { assignments } = useAssignments({}); - const {users} = useUsers(); - const {stats, isLoading: isStatsLoading} = useStats(statsUserId || user?.id); - const {groups: allGroups} = useGroups({}); - const {groups} = useGroups({admin: user?.id, userType: user?.type}); + const { users } = useUsers(); + const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser(statsUserId || user?.id); const setExams = useExamStore((state) => state.setExams); const setShowSolutions = useExamStore((state) => state.setShowSolutions); @@ -80,7 +74,6 @@ export default function History({user}: {user: User}) { const setInactivity = useExamStore((state) => state.setInactivity); const setTimeSpent = useExamStore((state) => state.setTimeSpent); const renderPdfIcon = usePDFDownload("stats"); - const router = useRouter(); useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]); @@ -107,16 +100,12 @@ export default function History({user}: {user: User}) { // if (!statsUserId) setStatsUserId(user.id); // }, []); - const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { - setFilter((prev) => (prev === value ? undefined : value)); - }; - - const filterStatsByDate = (stats: {[key: string]: Stat[]}) => { + const filterStatsByDate = (stats: { [key: string]: Stat[] }) => { if (filter && filter !== "assignments") { const filterDate = moment() - .subtract({[filter as string]: 1}) + .subtract({ [filter as string]: 1 }) .format("x"); - const filteredStats: {[key: string]: Stat[]} = {}; + const filteredStats: { [key: string]: Stat[] } = {}; Object.keys(stats).forEach((timestamp) => { if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp]; @@ -125,7 +114,7 @@ export default function History({user}: {user: User}) { } if (filter && filter === "assignments") { - const filteredStats: {[key: string]: Stat[]} = {}; + const filteredStats: { [key: string]: Stat[] } = {}; Object.keys(stats).forEach((timestamp) => { if (stats[timestamp].map((s) => s.assignment === undefined).includes(false)) @@ -138,20 +127,11 @@ export default function History({user}: {user: User}) { return stats; }; + const MAX_TRAINING_EXAMS = 10; const [selectedTrainingExams, setSelectedTrainingExams] = useState([]); const setTrainingStats = useTrainingContentStore((state) => state.setStats); - useEffect(() => { - const handleRouteChange = (url: string) => { - setTraining(false); - }; - router.events.on("routeChangeStart", handleRouteChange); - return () => { - router.events.off("routeChangeStart", handleRouteChange); - }; - }, [router.events, setTraining]); - const handleTrainingContentSubmission = () => { if (groupedStats) { const groupedStatsByDate = filterStatsByDate(groupedStats); @@ -168,6 +148,16 @@ export default function History({user}: {user: User}) { } }; + useEffect(() => { + const handleRouteChange = (url: string) => { + setTraining(false); + }; + router.events.on("routeChangeStart", handleRouteChange); + return () => { + router.events.off("routeChangeStart", handleRouteChange); + }; + }, [router.events, setTraining]); + const customContent = (timestamp: string) => { if (!groupedStats) return <>; @@ -196,52 +186,6 @@ export default function History({user}: {user: User}) { ); }; - const selectableCorporates = [ - defaultSelectableCorporate, - ...users - .filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)) - .filter((x) => x.type === "corporate") - .map((x) => ({ - value: x.id, - label: `${x.name} - ${x.email}`, - })), - ]; - - const [selectedCorporate, setSelectedCorporate] = useState(defaultSelectableCorporate.value); - - const getUsersList = (): User[] => { - if (selectedCorporate) { - const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate); - const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants); - - const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[]; - return userListWithUsers.filter((x) => x); - } - - return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)); - }; - - const corporateFilteredUserList = getUsersList(); - - const getSelectedUser = () => { - if (selectedCorporate) { - const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId); - return userInCorporate || corporateFilteredUserList[0]; - } - - return users.find((x) => x.id === statsUserId) || user; - }; - - const selectedUser = getSelectedUser(); - const selectedUserSelectValue = selectedUser - ? { - value: selectedUser.id, - label: `${selectedUser.name} - ${selectedUser.email}`, - } - : { - value: "", - label: "", - }; return ( <> @@ -256,125 +200,25 @@ export default function History({user}: {user: User}) { {user && ( -
-
- {checkAccess(user, ["developer", "admin", "mastercorporate"]) && !training && ( - <> - - - - - - groups.flatMap((y) => y.participants).includes(x.id)) - .map((x) => ({ - value: x.id, - label: `${x.name} - ${x.email}`, - }))} - value={selectedUserSelectValue} - 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, - }), - }} - /> - - )} - {training && ( -
-
- Select up to 10 exercises - {`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`} -
- + + {training && ( +
+
+ Select up to 10 exercises + {`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
- )} -
-
- - - - -
-
+ +
+ )} + {groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && (
{Object.keys(filterStatsByDate(groupedStats)) diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx index c3aedff1..b9c78c8c 100644 --- a/src/pages/stats.tsx +++ b/src/pages/stats.tsx @@ -5,7 +5,7 @@ import {LinearScale, Chart as ChartJS, CategoryScale, PointElement, LineElement, import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {useEffect, useState} from "react"; -import useStats from "@/hooks/useStats"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import {averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate} from "@/utils/stats"; import useUser from "@/hooks/useUser"; import {ToastContainer} from "react-toastify"; @@ -72,7 +72,7 @@ export default function Stats() { const {user} = useUser({redirectTo: "/login"}); const {users} = useUsers(); const {groups} = useGroups({admin: user?.id}); - const {stats} = useStats(statsUserId, !statsUserId); + const {data: stats} = useFilterRecordsByUser(statsUserId, !statsUserId); useEffect(() => { if (user) setStatsUserId(user.id); diff --git a/src/pages/training/[id]/index.tsx b/src/pages/training/[id]/index.tsx index ae902a29..a322b63f 100644 --- a/src/pages/training/[id]/index.tsx +++ b/src/pages/training/[id]/index.tsx @@ -18,7 +18,7 @@ import {withIronSessionSsr} from "iron-session/next"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {sessionOptions} from "@/lib/session"; import qs from "qs"; -import StatsGridItem from "@/components/StatGridItem"; +import StatsGridItem from "@/components/Medium/StatGridItem"; import useExamStore from "@/stores/examStore"; import {usePDFDownload} from "@/hooks/usePDFDownload"; import useAssignments from "@/hooks/useAssignments"; diff --git a/src/pages/training/index.tsx b/src/pages/training/index.tsx index 30ab8b66..3c074561 100644 --- a/src/pages/training/index.tsx +++ b/src/pages/training/index.tsx @@ -1,28 +1,27 @@ /* eslint-disable @next/next/no-img-element */ import Head from "next/head"; -import {withIronSessionSsr} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {Stat, User} from "@/interfaces/user"; -import {ToastContainer} from "react-toastify"; +import { withIronSessionSsr } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { User } from "@/interfaces/user"; +import { ToastContainer } from "react-toastify"; import Layout from "@/components/High/Layout"; -import {shouldRedirectHome} from "@/utils/navigation.disabled"; -import {use, useEffect, useState} from "react"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; +import { useEffect, useState } from "react"; import clsx from "clsx"; -import {FaPlus} from "react-icons/fa"; +import { FaPlus } from "react-icons/fa"; import useRecordStore from "@/stores/recordStore"; import router from "next/router"; import useTrainingContentStore from "@/stores/trainingContentStore"; import axios from "axios"; -import Select from "@/components/Low/Select"; -import useUsers from "@/hooks/useUsers"; -import useGroups from "@/hooks/useGroups"; -import {ITrainingContent} from "@/training/TrainingInterfaces"; +import { ITrainingContent } from "@/training/TrainingInterfaces"; import moment from "moment"; -import {uuidv4} from "@firebase/util"; +import { uuidv4 } from "@firebase/util"; import TrainingScore from "@/training/TrainingScore"; import ModuleBadge from "@/components/ModuleBadge"; +import RecordFilter from "@/components/Medium/RecordFilter"; +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { +export const getServerSideProps = withIronSessionSsr(({ req, res }) => { const user = req.session.user; if (!user || !user.isVerified) { @@ -44,37 +43,22 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { } return { - props: {user: req.session.user}, + props: { user: req.session.user }, }; }, sessionOptions); -const defaultSelectableCorporate = { - value: "", - label: "All", -}; - -const Training: React.FC<{user: User}> = ({user}) => { - // Record stuff - const {users} = useUsers(); - const [selectedCorporate, setSelectedCorporate] = useState(defaultSelectableCorporate.value); - const [statsUserId, setStatsUserId, setRecordTraining] = useRecordStore((state) => [ +const Training: React.FC<{ user: User }> = ({ user }) => { + const [recordUserId, setRecordTraining] = useRecordStore((state) => [ state.selectedUser, - state.setSelectedUser, state.setTraining, ]); - const {groups: allGroups} = useGroups({}); - const groups = allGroups.filter((x) => x.admin === user.id); - const [filter, setFilter] = useState<"months" | "weeks" | "days">(); - - const toggleFilter = (value: "months" | "weeks" | "days") => { - setFilter((prev) => (prev === value ? undefined : value)); - }; + const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]); - const [trainingContent, setTrainingContent] = useState([]); const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0); - const [isLoading, setIsLoading] = useState(true); - const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>(); + const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{ [key: string]: ITrainingContent }>(); + + const { data: trainingContent, isLoading: areRecordsLoading } = useFilterRecordsByUser(recordUserId || user?.id, undefined, "training"); useEffect(() => { const handleRouteChange = (url: string) => { @@ -90,7 +74,7 @@ const Training: React.FC<{user: User}> = ({user}) => { useEffect(() => { const postStats = async () => { try { - const response = await axios.post<{id: string}>(`/api/training`, stats); + const response = await axios.post<{ id: string }>(`/api/training`, { userID: user.id, stats: stats }); return response.data.id; } catch (error) { setIsNewContentLoading(false); @@ -108,31 +92,17 @@ const Training: React.FC<{user: User}> = ({user}) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNewContentLoading]); - useEffect(() => { - const loadTrainingContent = async () => { - try { - const response = await axios.get("/api/training"); - setTrainingContent(response.data); - setIsLoading(false); - } catch (error) { - setTrainingContent([]); - setIsLoading(false); - } - }; - loadTrainingContent(); - }, []); - const handleNewTrainingContent = () => { setRecordTraining(true); router.push("/record"); }; - const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => { + const filterTrainingContentByDate = (trainingContent: { [key: string]: ITrainingContent }) => { if (filter) { const filterDate = moment() - .subtract({[filter as string]: 1}) + .subtract({ [filter as string]: 1 }) .format("x"); - const filteredTrainingContent: {[key: string]: ITrainingContent} = {}; + const filteredTrainingContent: { [key: string]: ITrainingContent } = {}; Object.keys(trainingContent).forEach((timestamp) => { if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp]; @@ -147,68 +117,14 @@ const Training: React.FC<{user: User}> = ({user}) => { const grouped = trainingContent.reduce((acc, content) => { acc[content.created_at] = content; return acc; - }, {} as {[key: number]: ITrainingContent}); + }, {} as { [key: number]: ITrainingContent }); setGroupedByTrainingContent(grouped); + }else { + setGroupedByTrainingContent(undefined); } }, [trainingContent]); - // Record Stuff - const selectableCorporates = [ - defaultSelectableCorporate, - ...users - .filter((x) => x.type === "corporate") - .map((x) => ({ - value: x.id, - label: `${x.name} - ${x.email}`, - })), - ]; - - const getUsersList = (): User[] => { - if (selectedCorporate) { - // get groups for that corporate - const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate); - - // get the teacher ids for that group - const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants); - - // // search for groups for these teachers - // const teacherGroups = allGroups.filter((x) => { - // return selectedCorporateGroupsParticipants.includes(x.admin); - // }); - - // const usersList = [ - // ...selectedCorporateGroupsParticipants, - // ...teacherGroups.flatMap((x) => x.participants), - // ]; - const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[]; - return userListWithUsers.filter((x) => x); - } - - return users || []; - }; - - const corporateFilteredUserList = getUsersList(); - const getSelectedUser = () => { - if (selectedCorporate) { - const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId); - return userInCorporate || corporateFilteredUserList[0]; - } - - return users.find((x) => x.id === statsUserId) || user; - }; - - const selectedUser = getSelectedUser(); - const selectedUserSelectValue = selectedUser - ? { - value: selectedUser.id, - label: `${selectedUser.name} - ${selectedUser.email}`, - } - : { - value: "", - label: "", - }; - const formatTimestamp = (timestamp: string) => { const date = moment(parseInt(timestamp)); const formatter = "YYYY/MM/DD - HH:mm"; @@ -222,6 +138,7 @@ const Training: React.FC<{user: User}> = ({user}) => { const trainingContentContainer = (timestamp: string) => { if (!groupedByTrainingContent) return <>; + const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp]; const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))]; @@ -266,7 +183,7 @@ const Training: React.FC<{user: User}> = ({user}) => { - {isNewContentLoading || isLoading ? ( + {isNewContentLoading || areRecordsLoading ? (
{isNewContentLoading && ( @@ -275,120 +192,29 @@ const Training: React.FC<{user: User}> = ({user}) => {
) : ( <> -
-
- {(user.type === "developer" || user.type === "admin") && ( - <> - - - - - - groups.flatMap((y) => y.participants).includes(x.id)) - .map((x) => ({ - value: x.id, - label: `${x.name} - ${x.email}`, - }))} - value={selectedUserSelectValue} - 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 === "student" && ( - <> -
-
Generate New Training Material
- -
- - )} -
-
- - - -
-
+ + {user.type === "student" && ( + <> +
+
Generate New Training Material
+ +
+ + )} +
{trainingContent.length == 0 && (
No training content to display...
)} - {groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && ( + {!areRecordsLoading && groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent)) .sort((a, b) => parseInt(b) - parseInt(a))