Hooked training content to backend, did refactors to training components and record.tsx

This commit is contained in:
Carlos Mesquita
2024-08-01 20:40:31 +01:00
parent a534126c61
commit a71e6632d6
13 changed files with 1243 additions and 821 deletions

View File

@@ -0,0 +1,295 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import axios from 'axios';
import { Tab } from "@headlessui/react";
import { AiOutlineFileSearch } from "react-icons/ai";
import { MdOutlinePlaylistAddCheckCircle, MdOutlineSelfImprovement } from "react-icons/md";
import { BsChatLeftDots } from "react-icons/bs";
import Button from "@/components/Low/Button";
import clsx from "clsx";
import Exercise from "@/training/Exercise";
import TrainingScore from "@/training/TrainingScore";
import { ITrainingContent, ITrainingTip } from "@/training/TrainingInterfaces";
import { Stat, User } from '@/interfaces/user';
import Head from "next/head";
import Layout from "@/components/High/Layout";
import { ToastContainer } from 'react-toastify';
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 useExamStore from "@/stores/examStore";
import { usePDFDownload } from "@/hooks/usePDFDownload";
import useAssignments from '@/hooks/useAssignments';
import useUsers from '@/hooks/useUsers';
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: { user: req.session.user },
};
}, sessionOptions);
const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
// Record stuff
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const renderPdfIcon = usePDFDownload("stats");
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
const [loading, setLoading] = useState(true);
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
const [currentTipIndex, setCurrentTipIndex] = useState(0);
const { assignments } = useAssignments({});
const { users } = useUsers();
const router = useRouter();
const { id } = router.query;
useEffect(() => {
const fetchTrainingContent = async () => {
if (!id || typeof id !== 'string') return;
try {
setLoading(true);
const response = await axios.get<ITrainingContent>(`/api/training/${id}`);
const trainingContent = response.data;
const withExamsStats = {
...trainingContent,
exams: await Promise.all(trainingContent.exams.map(async (exam) => {
const stats = await Promise.all(exam.stat_ids.map(async (statId) => {
const statResponse = await axios.get<Stat>(`/api/stats/${statId}`);
return statResponse.data;
}));
return { ...exam, stats };
}))
};
const tips = await axios.get<ITrainingTip[]>('/api/training/walkthrough', {
params: { ids: trainingContent.tip_ids },
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' })
});
setTrainingTips(tips.data);
setTrainingContent(withExamsStats);
} catch (error) {
router.push('/training');
} finally {
setLoading(false);
}
};
fetchTrainingContent();
}, [id]);
const handleNext = () => {
setCurrentTipIndex((prevIndex) => (prevIndex + 1));
};
const handlePrevious = () => {
setCurrentTipIndex((prevIndex) => (prevIndex - 1));
};
return (
<>
<Head>
<title>Training | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
{loading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
</div>
) : (trainingContent && (
<>
<div className='flex flex-row gap-4'>
{trainingContent.exams.map((exam, examIndex) => (
<StatsGridItem
key={`exam-${examIndex}`}
stats={exam.stats || []}
timestamp={exam.date}
user={user}
assignments={assignments}
users={users}
setExams={setExams}
setShowSolutions={setShowSolutions}
setUserSolutions={setUserSolutions}
setSelectedModules={setSelectedModules}
setInactivity={setInactivity}
setTimeSpent={setTimeSpent}
renderPdfIcon={renderPdfIcon}
/>
))}
</div>
<div className='flex flex-col flex-grow'>
<div className='flex flex-row gap-10 -md:flex-col'>
<div className="rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full">
<div className="flex flex-row items-center mb-6 gap-1">
<MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2>
</div>
<TrainingScore
trainingContent={trainingContent}
gridView={false}
/>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row gap-2 items-center mb-6">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_168" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_168)">
<path d="M4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V7H4V19H19V21H4ZM8 17C7.45 17 6.97917 16.8042 6.5875 16.4125C6.19583 16.0208 6 15.55 6 15V3H23V15C23 15.55 22.8042 16.0208 22.4125 16.4125C22.0208 16.8042 21.55 17 21 17H8ZM8 15H21V5H8V15ZM10 12H14V7H10V12ZM15 12H19V10H15V12ZM15 9H19V7H15V9Z" fill="#53B2F9" />
</g>
</svg>
<h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3>
</div>
<ul>
{trainingContent.exams.flatMap((exam, index) => (
<li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border">
<div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2">
<span className="border-r-2 border-[#D9D9D929] pr-2">Exam {index + 1}</span>
<span className="pl-2">{exam.score}%</span>
</div>
<div className="flex flex-row items-center gap-2">
<BsChatLeftDots size={16} />
<p className="text-sm">{exam.performance_comment}</p>
</div>
</li>
))}
</ul>
</div>
<div className="rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full">
<div className="flex flex-row items-center mb-4 gap-1">
<MdOutlineSelfImprovement color={"#40A1EA"} size={24} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2>
</div>
<div className="bg-[#FBFBFB] border rounded-xl p-4">
<div className="flex flex-row items-center gap-1">
<div className="flex items-center justify-center w-[48px] h-[48px]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_445)">
<path d="M6 17H11V15H6V17ZM16 17H18V15H16V17ZM6 13H11V11H6V13ZM16 13H18V7H16V13ZM6 9H11V7H6V9ZM4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V5C2 4.45 2.19583 3.97917 2.5875 3.5875C2.97917 3.19583 3.45 3 4 3H20C20.55 3 21.0208 3.19583 21.4125 3.5875C21.8042 3.97917 22 4.45 22 5V19C22 19.55 21.8042 20.0208 21.4125 20.4125C21.0208 20.8042 20.55 21 20 21H4ZM4 19H20V5H4V19Z" fill="#1C1B1F" />
</g>
</svg>
</div>
<h3 className="text-lg font-semibold">Detailed Breakdown</h3>
</div>
<ul>
{trainingContent.exams.flatMap((exam, index) => (
<li key={index} className="mb-2 border rounded-lg p-4 bg-white">
<p> <span className="font-semibold mr-1">{`Exam ${index + 1}:`}</span><span>{exam.detailed_summary}</span></p>
</li>
))}
</ul>
</div>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row items-center mb-4 gap-1">
<AiOutlineFileSearch color="#40A1EA" size={24} />
<h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3>
</div>
<Tab.Group>
<div className="flex flex-col gap-4">
<Tab.List>
<div className="flex flex-row gap-6">
{trainingContent.weak_areas.map((x, index) => (
<Tab
key={index}
className={({ selected }) =>
clsx(
'text-[#53B2F9] pb-2 border-b-2',
'focus:outline-none',
selected ? 'border-[#1B78BE]' : 'border-[#1B78BE0F]'
)
}
>
{x.area}
</Tab>
))}
</div>
</Tab.List>
<Tab.Panels>
{trainingContent.weak_areas.map((x, index) => (
<Tab.Panel
key={index}
className="p-3 bg-[#FBFBFB] rounded-lg border border-[#0000000F]"
>
<p>{x.comment}</p>
</Tab.Panel>
))}
</Tab.Panels>
</div>
</Tab.Group>
</div>
</div>
</div>
<div className="rounded-3xl p-6 shadow-training-inset w-full">
<div className="flex flex-col p-10">
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
</div>
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={handlePrevious}
disabled={currentTipIndex == 0}
className="max-w-[200px] self-end w-full">
Previous
</Button>
<Button
color="purple"
disabled={currentTipIndex == (trainingTips.length - 1)}
onClick={handleNext}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</>
))}
</Layout>
</>
);
}
export default TrainingContent;

View File

@@ -0,0 +1,406 @@
/* 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 Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { use, useEffect, useState } from "react";
import clsx from "clsx";
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 moment from "moment";
import { uuidv4 } from "@firebase/util";
import TrainingScore from "@/training/TrainingScore";
import ModuleBadge from "@/components/ModuleBadge";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
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<string>(defaultSelectableCorporate.value);
const [statsUserId, setStatsUserId, 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 [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]);
const [trainingContent, setTrainingContent] = useState<ITrainingContent[]>([]);
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{ [key: string]: ITrainingContent }>();
useEffect(() => {
const handleRouteChange = (url: string) => {
setTrainingStats([])
}
router.events.on('routeChangeStart', handleRouteChange)
return () => {
router.events.off('routeChangeStart', handleRouteChange)
}
}, [router.events, setTrainingStats])
useEffect(() => {
const postStats = async () => {
try {
const response = await axios.post<{id: string}>(`/api/training`, stats);
return response.data.id;
} catch (error) {
setIsNewContentLoading(false);
}
};
if (isNewContentLoading) {
postStats().then( id => {
setTrainingStats([]);
if (id) {
router.push(`/training/${id}`)
}
});
}
}, [isNewContentLoading])
useEffect(() => {
const loadTrainingContent = async () => {
try {
const response = await axios.get<ITrainingContent[]>('/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 }) => {
if (filter) {
const filterDate = moment()
.subtract({ [filter as string]: 1 })
.format("x");
const filteredTrainingContent: { [key: string]: ITrainingContent } = {};
Object.keys(trainingContent).forEach((timestamp) => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
});
return filteredTrainingContent;
}
return trainingContent;
};
useEffect(() => {
if (trainingContent.length > 0) {
const grouped = trainingContent.reduce((acc, content) => {
acc[content.created_at] = content;
return acc;
}, {} as { [key: number]: ITrainingContent });
setGroupedByTrainingContent(grouped);
}
}, [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";
return date.format(formatter);
};
const selectTrainingContent = (trainingContent: ITrainingContent) => {
router.push(`/training/${trainingContent.id}`)
};
const trainingContentContainer = (timestamp: string) => {
if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
return (
<>
<div
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden"
)}
onClick={() => selectTrainingContent(trainingContent)}
role="button">
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
</div>
<div className="flex flex-col gap-2">
<div className="w-full flex flex-row gap-1">
{Object.values(groupedByTrainingContent || {}).flatMap((content) =>
content.exams.map(({ module, id }) => (
<ModuleBadge key={id} module={module} />
))
)}
</div>
</div>
</div>
<TrainingScore
trainingContent={trainingContent}
gridView={true}
/>
</div>
</>
);
};
return (
<>
<Head>
<title>Training | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
{(isNewContentLoading || isLoading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
{ isNewContentLoading && (<span className="text-center text-2xl font-bold text-mti-green-light">
Assessing your exams, please be patient...
</span>)}
</div>
) : (
<>
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4">
{(user.type === "developer" || user.type === "admin") && (
<>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
<Select
options={selectableCorporates}
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(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,
}),
}}></Select>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={corporateFilteredUserList.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 === "corporate" || user.type === "teacher") && groups.length > 0 && (
<>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={users
.filter((x) => 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" && (
<>
<div className="flex items-center">
<div className="font-semibold text-2xl">Generate New Training Material</div>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
"transition duration-300 ease-in-out",
)}
onClick={handleNewTrainingContent}>
<FaPlus />
</button>
</div>
</>
))}
</div>
<div className="flex gap-4 w-full justify-center xl:justify-end">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "months" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("months")}>
Last month
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div>
{trainingContent.length == 0 && (
<div className="flex flex-grow justify-center items-center">
<span className="font-semibold ml-1">No training content to display...</span>
</div>
)}
{groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6">
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
.sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)}
</div>
)}
</>
))}
</Layout>
</>
);
}
export default Training;