387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
|
import Button from "@/components/Low/Button";
|
|
import ProgressBar from "@/components/Low/ProgressBar";
|
|
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
|
|
import ModuleBadge from "@/components/ModuleBadge";
|
|
import ProfileSummary from "@/components/ProfileSummary";
|
|
import { Session } from "@/hooks/useSessions";
|
|
import { Grading, Module } from "@/interfaces";
|
|
import { EntityWithRoles } from "@/interfaces/entity";
|
|
import { Exam } from "@/interfaces/exam";
|
|
import { InviteWithEntity } from "@/interfaces/invite";
|
|
import { Assignment, AssignmentWithHasResults } from "@/interfaces/results";
|
|
import { User } from "@/interfaces/user";
|
|
import { sessionOptions } from "@/lib/session";
|
|
import useExamStore from "@/stores/exam";
|
|
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
|
import { requestUser } from "@/utils/api";
|
|
import { getAssignmentsForStudent } from "@/utils/assignments.be";
|
|
import { getExamsByIds } from "@/utils/exams.be";
|
|
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
|
import {
|
|
convertInvitersToEntity,
|
|
getInvitesByInvitee,
|
|
} from "@/utils/invites.be";
|
|
import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
|
|
import { checkAccess } from "@/utils/permissions";
|
|
import { getGradingLabel } from "@/utils/score";
|
|
import { getSessionsByUser } from "@/utils/sessions.be";
|
|
import { getDetailedStatsByUser } from "@/utils/stats.be";
|
|
import clsx from "clsx";
|
|
import { withIronSessionSsr } from "iron-session/next";
|
|
import { capitalize, uniqBy } from "lodash";
|
|
import moment from "moment";
|
|
import Head from "next/head";
|
|
import { useRouter } from "next/router";
|
|
import { useMemo } from "react";
|
|
import {
|
|
BsBook,
|
|
BsClipboard,
|
|
BsFileEarmarkText,
|
|
BsHeadphones,
|
|
BsMegaphone,
|
|
BsPen,
|
|
BsPencil,
|
|
BsStar,
|
|
} from "react-icons/bs";
|
|
import { ToastContainer } from "react-toastify";
|
|
|
|
interface Props {
|
|
user: User;
|
|
entities: EntityWithRoles[];
|
|
assignments: AssignmentWithHasResults[];
|
|
stats: { fullExams: number; uniqueModules: number; averageScore: number };
|
|
exams: Exam[];
|
|
sessions: Session[];
|
|
invites: InviteWithEntity[];
|
|
grading: Grading;
|
|
}
|
|
|
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|
const user = await requestUser(req, res);
|
|
if (!user || !user.isVerified) return redirect("/login");
|
|
|
|
if (!checkAccess(user, ["admin", "developer", "student"]))
|
|
return redirect("/");
|
|
|
|
const entityIDS = mapBy(user.entities, "id") || [];
|
|
const currentDate = moment().toISOString();
|
|
|
|
const [assignments, stats, invites, grading] = await Promise.all([
|
|
getAssignmentsForStudent(user.id, currentDate),
|
|
getDetailedStatsByUser(user.id, "stats"),
|
|
getInvitesByInvitee(user.id),
|
|
getGradingSystemByEntity(entityIDS[0] || "", {
|
|
_id: 0,
|
|
steps: 1,
|
|
}),
|
|
]);
|
|
const assignmentsIDs = mapBy(assignments, "id");
|
|
const [sessions, ...formattedInvites] = await Promise.all([
|
|
getSessionsByUser(user.id, 10, {
|
|
["assignment.id"]: { $in: assignmentsIDs },
|
|
}),
|
|
...invites.map(convertInvitersToEntity),
|
|
]);
|
|
|
|
const examIDs = uniqBy(
|
|
assignments.reduce<{ module: Module; id: string; key: string }[]>(
|
|
(acc, a) => {
|
|
a.exams.forEach((e: { module: Module; id: string }) => {
|
|
acc.push({
|
|
module: e.module,
|
|
id: e.id,
|
|
key: `${e.module}_${e.id}`,
|
|
});
|
|
});
|
|
return acc;
|
|
},
|
|
[]
|
|
),
|
|
"key"
|
|
);
|
|
|
|
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
|
|
|
|
return {
|
|
props: serialize({
|
|
user,
|
|
assignments,
|
|
stats: stats ,
|
|
exams,
|
|
sessions,
|
|
invites: formattedInvites,
|
|
grading,
|
|
}),
|
|
};
|
|
}, sessionOptions);
|
|
|
|
export default function Dashboard({
|
|
user,
|
|
entities,
|
|
assignments,
|
|
stats,
|
|
invites,
|
|
grading,
|
|
sessions,
|
|
exams,
|
|
}: Props) {
|
|
const router = useRouter();
|
|
|
|
const dispatch = useExamStore((state) => state.dispatch);
|
|
|
|
const startAssignment = (assignment: Assignment) => {
|
|
const assignmentExams = exams.filter((e) => {
|
|
const exam = findBy(assignment.exams, "id", e.id);
|
|
return !!exam && exam.module === e.module;
|
|
});
|
|
|
|
if (assignmentExams.every((x) => !!x)) {
|
|
dispatch({
|
|
type: "INIT_EXAM",
|
|
payload: {
|
|
exams: assignmentExams.sort(sortByModule),
|
|
modules: mapBy(assignmentExams.sort(sortByModule), "module"),
|
|
assignment,
|
|
},
|
|
});
|
|
|
|
router.push("/exam");
|
|
}
|
|
};
|
|
|
|
const entitiesLabels = useMemo(
|
|
() => (entities.length > 0 ? mapBy(entities, "label")?.join(", ") : ""),
|
|
[entities]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>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 />
|
|
<>
|
|
{entities.length > 0 && (
|
|
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
|
|
<b>{entitiesLabels}</b>
|
|
</div>
|
|
)}
|
|
|
|
<ProfileSummary
|
|
user={user}
|
|
items={[
|
|
{
|
|
icon: (
|
|
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
|
),
|
|
value: stats?.fullExams || 0,
|
|
label: "Exams",
|
|
tooltip: "Number of all conducted completed exams",
|
|
},
|
|
{
|
|
icon: (
|
|
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
|
),
|
|
value: stats?.uniqueModules || 0,
|
|
label: "Modules",
|
|
tooltip:
|
|
"Number of all exam modules performed including Level Test",
|
|
},
|
|
{
|
|
icon: (
|
|
<BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
|
|
),
|
|
value: `${stats?.averageScore.toFixed(2) || 0}%`,
|
|
label: "Average Score",
|
|
tooltip: "Average success rate for questions responded",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Assignments */}
|
|
<section className="flex flex-col gap-1 md:gap-3">
|
|
<span className="text-mti-black text-lg font-bold">Assignments</span>
|
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
|
{assignments.length === 0 &&
|
|
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
|
{assignments.map((assignment) => (
|
|
<div
|
|
className={clsx(
|
|
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
|
assignment.hasResults && "border-mti-green-light"
|
|
)}
|
|
key={assignment.id}
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<h3 className="text-mti-black/90 text-xl font-semibold">
|
|
{assignment.name}
|
|
</h3>
|
|
<span className="flex justify-between gap-1 text-lg">
|
|
<span>
|
|
{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}
|
|
</span>
|
|
<span>-</span>
|
|
<span>
|
|
{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex w-full items-center justify-between">
|
|
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
|
|
{assignment.exams.map((e) => (
|
|
<ModuleBadge
|
|
className="scale-110 w-full"
|
|
key={e.module}
|
|
module={e.module}
|
|
/>
|
|
))}
|
|
</div>
|
|
{!assignment.hasResults && (
|
|
<>
|
|
<div
|
|
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
|
data-tip="Your screen size is too small to perform an assignment"
|
|
>
|
|
<Button
|
|
className="h-full w-full !rounded-xl"
|
|
variant="outline"
|
|
>
|
|
Start
|
|
</Button>
|
|
</div>
|
|
<div
|
|
data-tip="You have already started this assignment!"
|
|
className={clsx(
|
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
|
sessions.filter(
|
|
(x) => x.assignment?.id === assignment.id
|
|
).length > 0 && "tooltip"
|
|
)}
|
|
>
|
|
<Button
|
|
className={clsx("w-full h-full !rounded-xl")}
|
|
onClick={() => startAssignment(assignment)}
|
|
variant="outline"
|
|
disabled={
|
|
sessions.filter(
|
|
(x) => x.assignment?.id === assignment.id
|
|
).length > 0
|
|
}
|
|
>
|
|
Start
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{assignment.hasResults && (
|
|
<Button
|
|
onClick={() => router.push("/record")}
|
|
color="green"
|
|
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
|
variant="outline"
|
|
>
|
|
Submitted
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</span>
|
|
</section>
|
|
|
|
{/* Invites */}
|
|
{invites.length > 0 && (
|
|
<section className="flex flex-col gap-1 md:gap-3">
|
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
|
{invites.map((invite) => (
|
|
<InviteWithUserCard
|
|
key={invite.id}
|
|
invite={invite}
|
|
reload={() => router.replace(router.asPath)}
|
|
/>
|
|
))}
|
|
</span>
|
|
</section>
|
|
)}
|
|
|
|
{/* Score History */}
|
|
<section className="flex flex-col gap-3">
|
|
<span className="text-lg font-bold">Score History</span>
|
|
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
|
{MODULE_ARRAY.map((module) => {
|
|
const desiredLevel = user.desiredLevels[module] || 9;
|
|
const level = user.levels[module] || 0;
|
|
return (
|
|
<div
|
|
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 w-full"
|
|
key={module}
|
|
>
|
|
<div className="flex items-center gap-2 md:gap-3">
|
|
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
|
{module === "reading" && (
|
|
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
|
|
)}
|
|
{module === "listening" && (
|
|
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
|
|
)}
|
|
{module === "writing" && (
|
|
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
|
|
)}
|
|
{module === "speaking" && (
|
|
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
|
|
)}
|
|
{module === "level" && (
|
|
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
|
|
)}
|
|
</div>
|
|
<div className="flex w-full justify-between">
|
|
<span className="text-sm font-bold md:font-extrabold">
|
|
{capitalize(module)}
|
|
</span>
|
|
<span className="text-mti-gray-dim text-sm font-normal">
|
|
{module === "level" &&
|
|
!!grading &&
|
|
`English Level: ${getGradingLabel(
|
|
level,
|
|
grading.steps
|
|
)}`}
|
|
{module !== "level" &&
|
|
`Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="md:pl-14">
|
|
<ProgressBar
|
|
color={module}
|
|
label=""
|
|
mark={
|
|
module === "level"
|
|
? undefined
|
|
: Math.round((desiredLevel * 100) / 9)
|
|
}
|
|
markLabel={`Desired Level: ${desiredLevel}`}
|
|
percentage={
|
|
module === "level"
|
|
? level
|
|
: Math.round((level * 100) / 9)
|
|
}
|
|
className="h-2 w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</>
|
|
</>
|
|
);
|
|
}
|