Merged in refactor-getserverprops (pull request #142)

Refactor most getServerProps to make independent requests in parallel and projected the data only to return the necessary fields and changed some functions

Approved-by: Tiago Ribeiro
This commit is contained in:
Francisco Lima
2025-01-30 20:02:27 +00:00
committed by Tiago Ribeiro
36 changed files with 5796 additions and 4058 deletions

View File

@@ -1,7 +1,10 @@
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments"; import {
activeAssignmentFilter,
futureAssignmentFilter,
} from "@/utils/assignments";
import { sortByModuleName } from "@/utils/moduleUtils"; import { sortByModuleName } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
@@ -11,102 +14,124 @@ import Button from "../Low/Button";
import ModuleBadge from "../ModuleBadge"; import ModuleBadge from "../ModuleBadge";
interface Props { interface Props {
assignment: Assignment assignment: Assignment;
user: User user: User;
session?: Session session?: Session;
startAssignment: (assignment: Assignment) => void startAssignment: (assignment: Assignment) => void;
resumeAssignment: (session: Session) => void resumeAssignment: (session: Session) => void;
} }
export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) { export default function AssignmentCard({
const router = useRouter() user,
assignment,
session,
startAssignment,
resumeAssignment,
}: Props) {
const hasBeenSubmitted = useMemo(
() => assignment.results.map((r) => r.user).includes(user.id),
[assignment.results, user.id]
);
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id]) return (
<div
return ( className={clsx(
<div "border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
className={clsx( assignment.results.map((r) => r.user).includes(user.id) &&
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4", "border-mti-green-light"
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light", )}
)} key={assignment.id}
key={assignment.id}> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3> <h3 className="text-mti-black/90 text-xl font-semibold">
<span className="flex justify-between gap-1 text-lg"> {assignment.name}
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span> </h3>
<span>-</span> <span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span> <span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
</span> <span>-</span>
</div> <span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
<div className="flex w-full items-center justify-between"> </span>
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4"> </div>
{assignment.exams <div className="flex w-full items-center justify-between">
.filter((e) => e.assignee === user.id) <div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
.map((e) => e.module) {assignment.exams
.sort(sortByModuleName) .filter((e) => e.assignee === user.id)
.map((module) => ( .map((e) => e.module)
<ModuleBadge className="scale-110 w-full" key={module} module={module} /> .sort(sortByModuleName)
))} .map((module) => (
</div> <ModuleBadge
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && ( className="scale-110 w-full"
<Button key={module}
color="rose" module={module}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" />
disabled ))}
variant="outline"> </div>
Not yet started {futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
</Button> <Button
)} color="rose"
{activeAssignmentFilter(assignment) && !hasBeenSubmitted && ( className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
<> disabled
<div variant="outline"
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"> Not yet started
<Button className="h-full w-full !rounded-xl" variant="outline"> </Button>
Start )}
</Button> {activeAssignmentFilter(assignment) && !hasBeenSubmitted && (
</div> <>
{!session && ( <div
<div className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="You have already started this assignment!" data-tip="Your screen size is too small to perform an assignment"
className={clsx( >
"-md:hidden h-full w-full max-w-[50%] cursor-pointer", <Button className="h-full w-full !rounded-xl" variant="outline">
!!session && "tooltip", Start
)}> </Button>
<Button </div>
className={clsx("w-full h-full !rounded-xl")} {!session && (
onClick={() => startAssignment(assignment)} <div
variant="outline"> data-tip="You have already started this assignment!"
Start className={clsx(
</Button> "-md:hidden h-full w-full max-w-[50%] cursor-pointer",
</div> !!session && "tooltip"
)} )}
{!!session && ( >
<div <Button
className={clsx( className={clsx("w-full h-full !rounded-xl")}
"-md:hidden h-full w-full max-w-[50%] cursor-pointer" onClick={() => startAssignment(assignment)}
)}> variant="outline"
<Button >
className={clsx("w-full h-full !rounded-xl")} Start
onClick={() => resumeAssignment(session)} </Button>
color="green" </div>
variant="outline"> )}
Resume {!!session && (
</Button> <div
</div> className={clsx(
)} "-md:hidden h-full w-full max-w-[50%] cursor-pointer"
</> )}
)} >
{hasBeenSubmitted && ( <Button
<Button className={clsx("w-full h-full !rounded-xl")}
color="green" onClick={() => resumeAssignment(session)}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" color="green"
disabled variant="outline"
variant="outline"> >
Submitted Resume
</Button> </Button>
)} </div>
</div> )}
</div> </>
) )}
{hasBeenSubmitted && (
<Button
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled
variant="outline"
>
Submitted
</Button>
)}
</div>
</div>
);
} }

View File

@@ -61,7 +61,14 @@ export default function App({ Component, pageProps }: AppProps) {
return pageProps?.user ? ( return pageProps?.user ? (
<Layout user={pageProps.user} entities={entities} refreshPage={loading}> <Layout user={pageProps.user} entities={entities} refreshPage={loading}>
{loading ? <UserProfileSkeleton /> : <Component {...pageProps} />} {loading ? (
// TODO: Change this later to a better loading screen (example: skeletons for each page)
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
<Component entities={entities} {...pageProps} />
)}
</Layout> </Layout>
) : ( ) : (
<Component {...pageProps} /> <Component {...pageProps} />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,16 +5,16 @@ import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import {
activeAssignmentFilter, activeAssignmentFilter,
archivedAssignmentFilter, archivedAssignmentFilter,
futureAssignmentFilter, futureAssignmentFilter,
pastAssignmentFilter, pastAssignmentFilter,
startHasExpiredAssignmentFilter, startHasExpiredAssignmentFilter,
} from "@/utils/assignments"; } from "@/utils/assignments";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -28,197 +28,309 @@ import { useMemo } from "react";
import { BsChevronLeft, BsPlus } from "react-icons/bs"; import { BsChevronLeft, BsPlus } from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) if (
return redirect("/") !checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/");
const isAdmin = checkAccess(user, ["developer", "admin"]);
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await (isAdmin
? getEntitiesWithRoles()
: getEntitiesWithRoles(entityIDS));
const entityIDS = mapBy(user.entities, "id") || []; const allowedEntities = findAllowedEntities(
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); user,
entities,
"view_assignments"
);
const [users, assignments] = await Promise.all([
await (isAdmin
? getUsers({}, 0, {}, { _id: 0, id: 1, name: 1 })
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
name: 1,
})),
await (isAdmin
? getAssignments()
: getEntitiesAssignments(mapBy(allowedEntities, "id"))),
]);
const allowedEntities = findAllowedEntities(user, entities, "view_assignments") return {
props: serialize({ user, users, entities: allowedEntities, assignments }),
const users = };
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
const assignments =
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
return { props: serialize({ user, users, entities: allowedEntities, assignments }) };
}, sessionOptions); }, sessionOptions);
const SEARCH_FIELDS = [["name"]]; const SEARCH_FIELDS = [["name"]];
interface Props { interface Props {
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
user: User; user: User;
users: User[]; users: User[];
} }
export default function AssignmentsPage({ assignments, entities, user, users }: Props) { export default function AssignmentsPage({
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment') assignments,
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment') entities,
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment') user,
users,
}: Props) {
const entitiesAllowCreate = useAllowedEntities(
user,
entities,
"create_assignment"
);
const entitiesAllowEdit = useAllowedEntities(
user,
entities,
"edit_assignment"
);
const entitiesAllowArchive = useAllowedEntities(
user,
entities,
"archive_assignment"
);
const activeAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); const activeAssignments = useMemo(
const plannedAssignments = useMemo(() => assignments.filter(futureAssignmentFilter), [assignments]); () => assignments.filter(activeAssignmentFilter),
const pastAssignments = useMemo(() => assignments.filter(pastAssignmentFilter), [assignments]); [assignments]
const startExpiredAssignments = useMemo(() => assignments.filter(startHasExpiredAssignmentFilter), [assignments]); );
const archivedAssignments = useMemo(() => assignments.filter(archivedAssignmentFilter), [assignments]); const plannedAssignments = useMemo(
() => assignments.filter(futureAssignmentFilter),
[assignments]
);
const pastAssignments = useMemo(
() => assignments.filter(pastAssignmentFilter),
[assignments]
);
const startExpiredAssignments = useMemo(
() => assignments.filter(startHasExpiredAssignmentFilter),
[assignments]
);
const archivedAssignments = useMemo(
() => assignments.filter(archivedAssignmentFilter),
[assignments]
);
const router = useRouter(); const router = useRouter();
const { rows: activeRows, renderSearch: renderActive } = useListSearch(SEARCH_FIELDS, activeAssignments); const { rows: activeRows, renderSearch: renderActive } = useListSearch(
const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(SEARCH_FIELDS, plannedAssignments); SEARCH_FIELDS,
const { rows: pastRows, renderSearch: renderPast } = useListSearch(SEARCH_FIELDS, pastAssignments); activeAssignments
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(SEARCH_FIELDS, startExpiredAssignments); );
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(SEARCH_FIELDS, archivedAssignments); const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(
SEARCH_FIELDS,
plannedAssignments
);
const { rows: pastRows, renderSearch: renderPast } = useListSearch(
SEARCH_FIELDS,
pastAssignments
);
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(
SEARCH_FIELDS,
startExpiredAssignments
);
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(
SEARCH_FIELDS,
archivedAssignments
);
const { items: activeItems, renderMinimal: paginationActive } = usePagination(activeRows, 16); const { items: activeItems, renderMinimal: paginationActive } = usePagination(
const { items: plannedItems, renderMinimal: paginationPlanned } = usePagination(plannedRows, 16); activeRows,
const { items: pastItems, renderMinimal: paginationPast } = usePagination(pastRows, 16); 16
const { items: expiredItems, renderMinimal: paginationExpired } = usePagination(expiredRows, 16); );
const { items: archivedItems, renderMinimal: paginationArchived } = usePagination(archivedRows, 16); const { items: plannedItems, renderMinimal: paginationPlanned } =
usePagination(plannedRows, 16);
const { items: pastItems, renderMinimal: paginationPast } = usePagination(
pastRows,
16
);
const { items: expiredItems, renderMinimal: paginationExpired } =
usePagination(expiredRows, 16);
const { items: archivedItems, renderMinimal: paginationArchived } =
usePagination(archivedRows, 16);
return ( return (
<> <>
<Head> <Head>
<title>Assignments | EnCoach</title> <title>Assignments | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
<BsChevronLeft /> href="/dashboard"
</Link> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
<h2 className="font-bold text-2xl">Assignments</h2> >
</div> <BsChevronLeft />
<Separator /> </Link>
</div> <h2 className="font-bold text-2xl">Assignments</h2>
<div className="flex flex-col gap-2"> </div>
<span className="text-lg font-bold">Active Assignments Status</span> <Separator />
<div className="flex items-center gap-4"> </div>
<span> <div className="flex flex-col gap-2">
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/ <span className="text-lg font-bold">Active Assignments Status</span>
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)} <div className="flex items-center gap-4">
</span> <span>
</div> <b>Total:</b>{" "}
</div> {activeAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
)}
/
{activeAssignments.reduce(
(acc, curr) => curr.exams.length + acc,
0
)}
</span>
</div>
</div>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({activeAssignments.length})</h2> <h2 className="text-2xl font-semibold">
<div className="w-full flex items-center gap-4"> Active Assignments ({activeAssignments.length})
{renderActive()} </h2>
{paginationActive()} <div className="w-full flex items-center gap-4">
</div> {renderActive()}
<div className="flex flex-wrap gap-2"> {paginationActive()}
{activeItems.map((a) => ( </div>
<AssignmentCard {...a} entityObj={findBy(entities, 'id', a.entity)} users={users} onClick={() => router.push(`/assignments/${a.id}`)} key={a.id} /> <div className="flex flex-wrap gap-2">
))} {activeItems.map((a) => (
</div> <AssignmentCard
</section> {...a}
entityObj={findBy(entities, "id", a.entity)}
users={users}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({plannedAssignments.length})</h2> <h2 className="text-2xl font-semibold">
<div className="w-full flex items-center gap-4"> Planned Assignments ({plannedAssignments.length})
{renderPlanned()} </h2>
{paginationPlanned()} <div className="w-full flex items-center gap-4">
</div> {renderPlanned()}
<div className="flex flex-wrap gap-2"> {paginationPlanned()}
<Link </div>
href={entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""} <div className="flex flex-wrap gap-2">
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> <Link
<BsPlus className="text-6xl" /> href={
<span className="text-lg">New Assignment</span> entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""
</Link> }
{plannedItems.map((a) => ( className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
<AssignmentCard >
{...a} <BsPlus className="text-6xl" />
users={users} <span className="text-lg">New Assignment</span>
entityObj={findBy(entities, 'id', a.entity)} </Link>
onClick={ {plannedItems.map((a) => (
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "") <AssignmentCard
? () => router.push(`/assignments/creator/${a.id}`) {...a}
: () => router.push(`/assignments/${a.id}`) users={users}
} entityObj={findBy(entities, "id", a.entity)}
key={a.id} onClick={
/> mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
))} ? () => router.push(`/assignments/creator/${a.id}`)
</div> : () => router.push(`/assignments/${a.id}`)
</section> }
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2> <h2 className="text-2xl font-semibold">
<div className="w-full flex items-center gap-4"> Past Assignments ({pastAssignments.length})
{renderPast()} </h2>
{paginationPast()} <div className="w-full flex items-center gap-4">
</div> {renderPast()}
<div className="flex flex-wrap gap-2"> {paginationPast()}
{pastItems.map((a) => ( </div>
<AssignmentCard <div className="flex flex-wrap gap-2">
{...a} {pastItems.map((a) => (
users={users} <AssignmentCard
entityObj={findBy(entities, 'id', a.entity)} {...a}
onClick={() => router.push(`/assignments/${a.id}`)} users={users}
key={a.id} entityObj={findBy(entities, "id", a.entity)}
allowDownload onClick={() => router.push(`/assignments/${a.id}`)}
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")} key={a.id}
allowExcelDownload allowDownload
/> allowArchive={mapBy(entitiesAllowArchive, "id").includes(
))} a.entity || ""
</div> )}
</section> allowExcelDownload
<section className="flex flex-col gap-4"> />
<h2 className="text-2xl font-semibold">Assignments start expired ({startExpiredAssignments.length})</h2> ))}
<div className="w-full flex items-center gap-4"> </div>
{renderExpired()} </section>
{paginationExpired()} <section className="flex flex-col gap-4">
</div> <h2 className="text-2xl font-semibold">
<div className="flex flex-wrap gap-2"> Assignments start expired ({startExpiredAssignments.length})
{expiredItems.map((a) => ( </h2>
<AssignmentCard <div className="w-full flex items-center gap-4">
{...a} {renderExpired()}
users={users} {paginationExpired()}
entityObj={findBy(entities, 'id', a.entity)} </div>
onClick={() => router.push(`/assignments/${a.id}`)} <div className="flex flex-wrap gap-2">
key={a.id} {expiredItems.map((a) => (
allowDownload <AssignmentCard
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")} {...a}
allowExcelDownload users={users}
/> entityObj={findBy(entities, "id", a.entity)}
))} onClick={() => router.push(`/assignments/${a.id}`)}
</div> key={a.id}
</section> allowDownload
<section className="flex flex-col gap-4"> allowArchive={mapBy(entitiesAllowArchive, "id").includes(
<h2 className="text-2xl font-semibold">Archived Assignments ({archivedAssignments.length})</h2> a.entity || ""
<div className="w-full flex items-center gap-4"> )}
{renderArchived()} allowExcelDownload
{paginationArchived()} />
</div> ))}
<div className="flex flex-wrap gap-2"> </div>
{archivedItems.map((a) => ( </section>
<AssignmentCard <section className="flex flex-col gap-4">
{...a} <h2 className="text-2xl font-semibold">
users={users} Archived Assignments ({archivedAssignments.length})
onClick={() => router.push(`/assignments/${a.id}`)} </h2>
key={a.id} <div className="w-full flex items-center gap-4">
entityObj={findBy(entities, 'id', a.entity)} {renderArchived()}
allowDownload {paginationArchived()}
allowUnarchive </div>
allowExcelDownload <div className="flex flex-wrap gap-2">
/> {archivedItems.map((a) => (
))} <AssignmentCard
</div> {...a}
</section> users={users}
</> onClick={() => router.push(`/assignments/${a.id}`)}
</> key={a.id}
); entityObj={findBy(entities, "id", a.entity)}
allowDownload
allowUnarchive
allowExcelDownload
/>
))}
</div>
</section>
</>
</>
);
} }

View File

@@ -18,328 +18,476 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { capitalize } from "lodash"; import { capitalize, last } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX } from "react-icons/bs"; import {
BsBuilding,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
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 { id } = params as { id: string }; const { id } = params as { id: string };
const group = await getGroup(id); const group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms") if (!group || !group.entity) return redirect("/classrooms");
const entity = await getEntityWithRoles(group.entity) const entity = await getEntityWithRoles(group.entity);
if (!entity) return redirect("/classrooms") if (!entity) return redirect("/classrooms");
const canView = doesEntityAllow(user, entity, "view_classrooms") const canView = doesEntityAllow(user, entity, "view_classrooms");
if (!canView) return redirect("/") if (!canView) return redirect("/");
const [linkedUsers, users] = await Promise.all([
getEntityUsers(
entity.id,
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}
),
getSpecificUsers([...group.participants, group.admin], {
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}),
]);
const linkedUsers = await getEntityUsers(entity.id) const groupWithUser = convertToUsers(group, users);
const users = await getSpecificUsers([...group.participants, group.admin]);
const groupWithUser = convertToUsers(group, users);
return { return {
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }), props: serialize({
}; user,
}, sessionOptions); group: groupWithUser,
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
entity,
}),
};
},
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
group: GroupWithUsers; group: GroupWithUsers;
users: User[]; users: User[];
entity: EntityWithRoles entity: EntityWithRoles;
} }
export default function Home({ user, group, users, entity }: Props) { export default function Home({ user, group, users, entity }: Props) {
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom") const canAddParticipants = useEntityPermission(
const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom") user,
const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms") entity,
const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom") "add_to_classroom"
);
const canRemoveParticipants = useEntityPermission(
user,
entity,
"remove_from_classroom"
);
const canRenameClassroom = useEntityPermission(
user,
entity,
"rename_classrooms"
);
const canDeleteClassroom = useEntityPermission(
user,
entity,
"delete_classroom"
);
const nonParticipantUsers = useMemo( const nonParticipantUsers = useMemo(
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)), () =>
[users, group.participants, group.admin.id, user.id], users.filter(
); (x) =>
![
...group.participants.map((g) => g.id),
group.admin.id,
user.id,
].includes(x.id)
),
[users, group.participants, group.admin.id, user.id]
);
const { rows, renderSearch } = useListSearch<User>( const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]], [["name"], ["corporateInformation", "companyInformation", "name"]],
isAdding ? nonParticipantUsers : group.participants, isAdding ? nonParticipantUsers : group.participants
); );
const { items, renderMinimal } = usePagination<User>(rows, 20); const { items, renderMinimal } = usePagination<User>(rows, 20);
const router = useRouter(); const router = useRouter();
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
const removeParticipants = () => { const removeParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return; if (!canRemoveParticipants) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`)) if (
return; !confirm(
`Are you sure you want to remove ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} from this group?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) }) .patch(`/api/groups/${group.id}`, {
.then(() => { participants: group.participants
toast.success("The group has been updated successfully!"); .map((x) => x.id)
router.replace(router.asPath); .filter((x) => !selectedUsers.includes(x)),
}) })
.catch((e) => { .then(() => {
console.error(e); toast.success("The group has been updated successfully!");
toast.error("Something went wrong!"); router.replace(router.asPath);
}) })
.finally(() => setIsLoading(false)); .catch((e) => {
}; console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const addParticipants = () => { const addParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canAddParticipants || !isAdding) return; if (!canAddParticipants || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`)) if (
return; !confirm(
`Are you sure you want to add ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} to this group?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] }) .patch(`/api/groups/${group.id}`, {
.then(() => { participants: [
toast.success("The group has been updated successfully!"); ...group.participants.map((x) => x.id),
router.replace(router.asPath); ...selectedUsers,
}) ],
.catch((e) => { })
console.error(e); .then(() => {
toast.error("Something went wrong!"); toast.success("The group has been updated successfully!");
}) router.replace(router.asPath);
.finally(() => setIsLoading(false)); })
}; .catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const renameGroup = () => { const renameGroup = () => {
if (!canRenameClassroom) return; if (!canRenameClassroom) return;
const name = prompt("Rename this classroom:", group.name); const name = prompt("Rename this classroom:", group.name);
if (!name) return; if (!name) return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/groups/${group.id}`, { name }) .patch(`/api/groups/${group.id}`, { name })
.then(() => { .then(() => {
toast.success("The classroom has been updated successfully!"); toast.success("The classroom has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.error("Something went wrong!"); toast.error("Something went wrong!");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const deleteGroup = () => { const deleteGroup = () => {
if (!canDeleteClassroom) return; if (!canDeleteClassroom) return;
if (!confirm("Are you sure you want to delete this classroom?")) return; if (!confirm("Are you sure you want to delete this classroom?")) return;
setIsLoading(true); setIsLoading(true);
axios axios
.delete(`/api/groups/${group.id}`) .delete(`/api/groups/${group.id}`)
.then(() => { .then(() => {
toast.success("This classroom has been successfully deleted!"); toast.success("This classroom has been successfully deleted!");
router.replace("/classrooms"); router.replace("/classrooms");
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.error("Something went wrong!"); toast.error("Something went wrong!");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(() => setSelectedUsers([]), [isAdding]); useEffect(() => setSelectedUsers([]), [isAdding]);
return ( return (
<> <>
<Head> <Head>
<title>{group.name} | EnCoach</title> <title>{group.name} | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
<BsChevronLeft /> >
</Link> <BsChevronLeft />
<h2 className="font-bold text-2xl">{group.name}</h2> </Link>
</div> <h2 className="font-bold text-2xl">{group.name}</h2>
</div>
{!isAdding && ( {!isAdding && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={renameGroup} onClick={renameGroup}
disabled={isLoading || !canRenameClassroom} disabled={isLoading || !canRenameClassroom}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
<BsTag /> >
<span className="text-xs">Rename Classroom</span> <BsTag />
</button> <span className="text-xs">Rename Classroom</span>
<button </button>
onClick={deleteGroup} <button
disabled={isLoading || !canDeleteClassroom} onClick={deleteGroup}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> disabled={isLoading || !canDeleteClassroom}
<BsTrash /> className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
<span className="text-xs">Delete Classroom</span> >
</button> <BsTrash />
</div> <span className="text-xs">Delete Classroom</span>
)} </button>
</div> </div>
<div className="flex flex-col gap-2"> )}
<span className="flex items-center gap-2"> </div>
<BsBuilding className="text-xl" /> {entity.label} <div className="flex flex-col gap-2">
</span> <span className="flex items-center gap-2">
<span className="flex items-center gap-2"> <BsBuilding className="text-xl" /> {entity.label}
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)} </span>
</span> <span className="flex items-center gap-2">
</div> <BsFillPersonVcardFill className="text-xl" />{" "}
</div> {getUserName(group.admin)}
<Divider /> </span>
<div className="flex items-center justify-between mb-4"> </div>
<span className="font-semibold text-xl">Participants</span> </div>
{!isAdding && ( <Divider />
<div className="flex items-center gap-2"> <div className="flex items-center justify-between mb-4">
<button <span className="font-semibold text-xl">Participants</span>
onClick={() => setIsAdding(true)} {!isAdding && (
disabled={isLoading || !canAddParticipants} <div className="flex items-center gap-2">
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> <button
<BsPlus /> onClick={() => setIsAdding(true)}
<span className="text-xs">Add Participants</span> disabled={isLoading || !canAddParticipants}
</button> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
<button >
onClick={removeParticipants} <BsPlus />
disabled={selectedUsers.length === 0 || isLoading || !canRemoveParticipants} <span className="text-xs">Add Participants</span>
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> </button>
<BsTrash /> <button
<span className="text-xs">Remove Participants</span> onClick={removeParticipants}
</button> disabled={
</div> selectedUsers.length === 0 ||
)} isLoading ||
{isAdding && ( !canRemoveParticipants
<div className="flex items-center gap-2"> }
<button className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
onClick={() => setIsAdding(false)} >
disabled={isLoading} <BsTrash />
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> <span className="text-xs">Remove Participants</span>
<BsX /> </button>
<span className="text-xs">Discard Selection</span> </div>
</button> )}
<button {isAdding && (
onClick={addParticipants} <div className="flex items-center gap-2">
disabled={selectedUsers.length === 0 || isLoading || !canAddParticipants} <button
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> onClick={() => setIsAdding(false)}
<BsPlus /> disabled={isLoading}
<span className="text-xs">Add Participants</span> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
</button> >
</div> <BsX />
)} <span className="text-xs">Discard Selection</span>
</div> </button>
<div className="w-full flex items-center gap-4"> <button
{renderSearch()} onClick={addParticipants}
{renderMinimal()} disabled={
</div> selectedUsers.length === 0 ||
<div className="flex items-center gap-2 mt-4"> isLoading ||
{['student', 'teacher', 'corporate'].map((type) => ( !canAddParticipants
<button }
key={type} className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
onClick={() => { >
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id') <BsPlus />
if (typeUsers.every((u) => selectedUsers.includes(u))) { <span className="text-xs">Add Participants</span>
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a))); </button>
} else { </div>
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]); )}
} </div>
}} <div className="w-full flex items-center gap-4">
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0} {renderSearch()}
className={clsx( {renderMinimal()}
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", </div>
"transition duration-300 ease-in-out", <div className="flex items-center gap-2 mt-4">
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed", {["student", "teacher", "corporate"].map((type) => (
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length > 0 && <button
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) && key={type}
"!bg-mti-purple-light !text-white", onClick={() => {
)}> const typeUsers = mapBy(
{capitalize(type)} filterBy(
</button> isAdding ? nonParticipantUsers : group.participants,
))} "type",
</div> type
</section> ),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else {
setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
}
}}
disabled={
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).length === 0
}
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",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).length > 0 &&
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)}
</button>
))}
</div>
</section>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => ( {items.map((u) => (
<button <button
onClick={() => toggleUser(u)} onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants} disabled={
key={u.id} isAdding ? !canAddParticipants : !canRemoveParticipants
className={clsx( }
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", key={u.id}
"hover:border-mti-purple transition ease-in-out duration-300", className={clsx(
selectedUsers.includes(u.id) && "border-mti-purple", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
)}> "hover:border-mti-purple transition ease-in-out duration-300",
<div className="flex items-center gap-2"> selectedUsers.includes(u.id) && "border-mti-purple"
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> )}
<img src={u.profilePicture} alt={u.name} /> >
</div> <div className="flex items-center gap-2">
<div className="flex flex-col"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<span className="font-semibold">{getUserName(u)}</span> <img src={u.profilePicture} alt={u.name} />
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> </div>
</div> <div className="flex flex-col">
</div> <span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address"> <Tooltip tooltip="E-mail address">
<BsEnvelopeFill /> <BsEnvelopeFill />
</Tooltip> </Tooltip>
{u.email} {u.email}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
</span> ? moment(u.subscriptionExpirationDate).format(
<span className="flex items-center gap-2"> "DD/MM/YYYY"
<Tooltip tooltip="Last Login"> )
<BsClockFill /> : "Unlimited"}
</Tooltip> </span>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} <span className="flex items-center gap-2">
</span> <Tooltip tooltip="Last Login">
</div> <BsClockFill />
</button> </Tooltip>
))} {u.lastLogin
</section> ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
</> : "N/A"}
)} </span>
</> </div>
); </button>
))}
</section>
</>
)}
</>
);
} }

View File

@@ -2,221 +2,308 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import {EntityWithRoles} from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import {filterBy, mapBy, redirect, serialize} from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles} from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {getUserName, isAdmin} from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
import {getEntitiesUsers} from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {Divider} from "primereact/divider"; import { Divider } from "primereact/divider";
import {useEffect, useMemo, useState} from "react"; import { useEffect, useMemo, useState } from "react";
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs"; import {
import {toast, ToastContainer} from "react-toastify"; BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : mapBy(user.entities, "id")); const entities = await getEntitiesWithRoles(
const users = await getEntitiesUsers(mapBy(entities, 'id')) isAdmin(user) ? undefined : mapBy(user.entities, "id")
const allowedEntities = findAllowedEntities(user, entities, "create_classroom") );
const users = await getEntitiesUsers(
mapBy(entities, "id"),
{
id: { $ne: user.id },
},
0,
{
_id: 0,
id: 1,
name: 1,
email: 1,
profilePicture: 1,
type: 1,
corporateInformation: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
}
);
const allowedEntities = findAllowedEntities(
user,
entities,
"create_classroom"
);
return { return {
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}), props: serialize({
}; user,
entities: allowedEntities,
users: users,
}),
};
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
} }
export default function Home({user, users, entities}: Props) { export default function Home({ user, users, entities }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users]) const entityUsers = useMemo(
() =>
!entity
? users
: users.filter((u) => mapBy(u.entities, "id").includes(entity)),
[entity, users]
);
const {rows, renderSearch} = useListSearch<User>( const { rows, renderSearch } = useListSearch<User>(
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers [
); ["name"],
["type"],
["corporateInformation", "companyInformation", "name"],
],
entityUsers
);
const {items, renderMinimal} = usePagination<User>(rows, 16); const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter(); const router = useRouter();
useEffect(() => setSelectedUsers([]), [entity]) useEffect(() => setSelectedUsers([]), [entity]);
const createGroup = () => { const createGroup = () => {
if (!name.trim()) return; if (!name.trim()) return;
if (!entity) return; if (!entity) return;
if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return; if (
!confirm(
`Are you sure you want to create this group with ${selectedUsers.length} participants?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity}) .post<{ id: string }>(`/api/groups`, {
.then((result) => { name,
toast.success("Your group has been created successfully!"); participants: selectedUsers,
router.replace(`/classrooms/${result.data.id}`); admin: user.id,
}) entity,
.catch((e) => { })
console.error(e); .then((result) => {
toast.error("Something went wrong!"); toast.success("Your group has been created successfully!");
}) router.replace(`/classrooms/${result.data.id}`);
.finally(() => setIsLoading(false)); })
}; .catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
return ( return (
<> <>
<Head> <Head>
<title>Create Group | EnCoach</title> <title>Create Group | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between"> <div className="flex gap-3 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
<BsChevronLeft /> >
</Link> <BsChevronLeft />
<h2 className="font-bold text-2xl">Create Classroom</h2> </Link>
</div> <h2 className="font-bold text-2xl">Create Classroom</h2>
<div className="flex items-center gap-4"> </div>
<button <div className="flex items-center gap-4">
onClick={createGroup} <button
disabled={!name.trim() || !entity || isLoading} onClick={createGroup}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> disabled={!name.trim() || !entity || isLoading}
<BsCheck /> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
<span className="text-xs">Create Classroom</span> >
</button> <BsCheck />
</div> <span className="text-xs">Create Classroom</span>
</div> </button>
<Divider /> </div>
<div className="grid grid-cols-2 gap-4 place-items-end"> </div>
<div className="flex flex-col gap-4 w-full"> <Divider />
<span className="font-semibold text-xl">Classroom Name:</span> <div className="grid grid-cols-2 gap-4 place-items-end">
<Input name="name" onChange={setName} type="text" placeholder="Classroom A" /> <div className="flex flex-col gap-4 w-full">
</div> <span className="font-semibold text-xl">Classroom Name:</span>
<div className="flex flex-col gap-4 w-full"> <Input
<span className="font-semibold text-xl">Entity:</span> name="name"
<Select onChange={setName}
options={entities.map((e) => ({value: e.id, label: e.label}))} type="text"
onChange={(v) => setEntity(v ? v.value! : undefined)} placeholder="Classroom A"
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}} />
/> </div>
</div> <div className="flex flex-col gap-4 w-full">
</div> <span className="font-semibold text-xl">Entity:</span>
<Divider /> <Select
<div className="flex items-center justify-between mb-4"> options={entities.map((e) => ({ value: e.id, label: e.label }))}
<span className="font-semibold text-xl">Participants ({selectedUsers.length} selected):</span> onChange={(v) => setEntity(v ? v.value! : undefined)}
</div> defaultValue={{
<div className="w-full flex items-center gap-4"> value: entities[0]?.id,
{renderSearch()} label: entities[0]?.label,
{renderMinimal()} }}
</div> />
<div className="flex items-center gap-2 mt-4"> </div>
{['student', 'teacher', 'corporate'].map((type) => ( </div>
<button <Divider />
key={type} <div className="flex items-center justify-between mb-4">
onClick={() => { <span className="font-semibold text-xl">
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id') Participants ({selectedUsers.length} selected):
if (typeUsers.every((u) => selectedUsers.includes(u))) { </span>
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a))); </div>
} else { <div className="w-full flex items-center gap-4">
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]); {renderSearch()}
} {renderMinimal()}
}} </div>
disabled={filterBy(entityUsers, 'type', type).length === 0} <div className="flex items-center gap-2 mt-4">
className={clsx( {["student", "teacher", "corporate"].map((type) => (
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", <button
"transition duration-300 ease-in-out", key={type}
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed", onClick={() => {
filterBy(entityUsers, 'type', type).length > 0 && const typeUsers = mapBy(
filterBy(entityUsers, 'type', type).every((u) => selectedUsers.includes(u.id)) && filterBy(entityUsers, "type", type),
"!bg-mti-purple-light !text-white", "id"
)}> );
{capitalize(type)} if (typeUsers.every((u) => selectedUsers.includes(u))) {
</button> setSelectedUsers((prev) =>
))} prev.filter((a) => !typeUsers.includes(a))
</div> );
</section> } else {
setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
}
}}
disabled={filterBy(entityUsers, "type", type).length === 0}
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",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(entityUsers, "type", type).length > 0 &&
filterBy(entityUsers, "type", type).every((u) =>
selectedUsers.includes(u.id)
) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)}
</button>
))}
</div>
</section>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => ( {items.map((u) => (
<button <button
onClick={() => toggleUser(u)} onClick={() => toggleUser(u)}
disabled={isLoading} disabled={isLoading}
key={u.id} key={u.id}
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
<div className="flex items-center gap-2"> >
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="flex items-center gap-2">
<img src={u.profilePicture} alt={u.name} /> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
</div> <img src={u.profilePicture} alt={u.name} />
<div className="flex flex-col"> </div>
<span className="font-semibold">{getUserName(u)}</span> <div className="flex flex-col">
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="font-semibold">{getUserName(u)}</span>
</div> <span className="opacity-80 text-sm">
</div> {USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address"> <Tooltip tooltip="E-mail address">
<BsEnvelopeFill /> <BsEnvelopeFill />
</Tooltip> </Tooltip>
{u.email} {u.email}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
</span> ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
<span className="flex items-center gap-2"> : "Unlimited"}
<Tooltip tooltip="Last Login"> </span>
<BsClockFill /> <span className="flex items-center gap-2">
</Tooltip> <Tooltip tooltip="Last Login">
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} <BsClockFill />
</span> </Tooltip>
</div> {u.lastLogin
</button> ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
))} : "N/A"}
</section> </span>
</> </div>
</> </button>
); ))}
</section>
</>
</>
);
} }

View File

@@ -27,132 +27,182 @@ import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTrans
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const entityIDS = mapBy(user.entities, "id"); const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS) const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms") isAdmin(user) ? undefined : entityIDS
);
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id')); const allowedEntities = findAllowedEntities(
user,
entities,
"view_classrooms"
);
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants, g.admin]))); const groups = await getGroupsForEntities(mapBy(allowedEntities, "id"));
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
return { const users = await getSpecificUsers(
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }), uniq(groups.flatMap((g) => [...g.participants, g.admin])),
}; { _id: 0, id: 1, name: 1, email: 1, corporateInformation: 1, type: 1 }
);
const groupsWithUsers: GroupWithUsers[] = groups.map((g) =>
convertToUsers(
g,
users.filter((x) => (isAdmin(user) ? true : !isAdmin(x)))
)
);
return {
props: serialize({
user,
groups: groupsWithUsers,
entities: allowedEntities,
}),
};
}, sessionOptions); }, sessionOptions);
const SEARCH_FIELDS = [ const SEARCH_FIELDS = [
["name"], ["name"],
["admin", "name"], ["admin", "name"],
["admin", "email"], ["admin", "email"],
["admin", "corporateInformation", "companyInformation", "name"], ["admin", "corporateInformation", "companyInformation", "name"],
["participants", "name"], ["participants", "name"],
["participants", "email"], ["participants", "email"],
["participants", "corporateInformation", "companyInformation", "name"], ["participants", "corporateInformation", "companyInformation", "name"],
]; ];
interface Props { interface Props {
user: User; user: User;
groups: GroupWithUsers[]; groups: GroupWithUsers[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
} }
export default function Home({ user, groups, entities }: Props) { export default function Home({ user, groups, entities }: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom'); const entitiesAllowCreate = useAllowedEntities(
const [showImport, setShowImport] = useState(false); user,
entities,
"create_classroom"
);
const [showImport, setShowImport] = useState(false);
const renderCard = (group: GroupWithUsers) => ( const renderCard = (group: GroupWithUsers) => (
<Link <Link
href={`/classrooms/${group.id}`} href={`/classrooms/${group.id}`}
key={group.id} key={group.id}
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
<div className="flex flex-col gap-2 w-full"> >
<span className="flex items-center gap-1"> <div className="flex flex-col gap-2 w-full">
<span className="bg-mti-purple text-white font-semibold px-2">Classroom</span> <span className="flex items-center gap-1">
{group.name} <span className="bg-mti-purple text-white font-semibold px-2">
</span> Classroom
<span className="flex items-center gap-1"> </span>
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span> {group.name}
{getUserName(group.admin)} </span>
</span> <span className="flex items-center gap-1">
{!!group.entity && ( <span className="bg-mti-purple text-white font-semibold px-2">
<span className="flex items-center gap-1"> Admin
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span> </span>
{findBy(entities, 'id', group.entity)?.label} {getUserName(group.admin)}
</span> </span>
)} {!!group.entity && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Participants</span> <span className="bg-mti-purple text-white font-semibold px-2">
<span className="bg-mti-purple-light/50 px-2">{group.participants.length}</span> Entity
</span> </span>
<span> {findBy(entities, "id", group.entity)?.label}
{group.participants.slice(0, 3).map(getUserName).join(", ")}{' '} </span>
{group.participants.length > 3 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {group.participants.length - 3} more</span> : ""} )}
</span> <span className="flex items-center gap-1">
</div> <span className="bg-mti-purple text-white font-semibold px-2">
<div className="w-fit"> Participants
<FaPersonChalkboard className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" /> </span>
</div> <span className="bg-mti-purple-light/50 px-2">
</Link> {group.participants.length}
); </span>
</span>
<span>
{group.participants.slice(0, 3).map(getUserName).join(", ")}{" "}
{group.participants.length > 3 ? (
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
and {group.participants.length - 3} more
</span>
) : (
""
)}
</span>
</div>
<div className="w-fit">
<FaPersonChalkboard className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
</div>
</Link>
);
const firstCard = () => ( const firstCard = () => (
<Link <Link
href={`/classrooms/create`} href={`/classrooms/create`}
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
<BsPlus size={40} /> >
<span className="font-semibold">Create Classroom</span> <BsPlus size={40} />
</Link> <span className="font-semibold">Create Classroom</span>
); </Link>
);
return ( return (
<> <>
<Head> <Head>
<title>Classrooms | EnCoach</title> <title>Classrooms | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]"> <Modal
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} /> isOpen={showImport}
</Modal> onClose={() => setShowImport(false)}
<div className="flex flex-col gap-4"> maxWidth="max-w-[85%]"
<div className="flex justify-between"> >
<h2 className="font-bold text-2xl">Classrooms</h2> <StudentClassroomTransfer
{entitiesAllowCreate.length !== 0 && <button user={user}
className={clsx( entities={entities}
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg", onFinish={() => setShowImport(false)}
"bg-mti-purple-light border border-mti-purple-light rounded-xl text-white", />
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out", </Modal>
)} <div className="flex flex-col gap-4">
onClick={() => setShowImport(true)} <div className="flex justify-between">
> <h2 className="font-bold text-2xl">Classrooms</h2>
<FaFileUpload className="w-5 h-5" /> {entitiesAllowCreate.length !== 0 && (
Transfer Students <button
</button> className={clsx(
} "flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
</div> "bg-mti-purple-light border border-mti-purple-light rounded-xl text-white",
<Separator /> "hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out"
</div> )}
onClick={() => setShowImport(true)}
>
<FaFileUpload className="w-5 h-5" />
Transfer Students
</button>
)}
</div>
<Separator />
</div>
<CardList<GroupWithUsers> <CardList<GroupWithUsers>
list={groups} list={groups}
searchFields={SEARCH_FIELDS} searchFields={SEARCH_FIELDS}
renderCard={renderCard} renderCard={renderCard}
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard} firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
/> />
</section> </section>
</> </>
</> </>
); );
} }

View File

@@ -4,20 +4,15 @@ import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, Type, User } from "@/interfaces/user"; import { Stat, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import { countEntitiesAssignments } from "@/utils/assignments.be";
countEntitiesAssignments,
} from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be"; import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be"; import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { import { countUsersByTypes, getUsers } from "@/utils/users.be";
countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -49,49 +44,48 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user || !user.isVerified) return redirect("/login"); if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/"); if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const [
const students = await getUsers( entities,
{ type: "student" }, usersCount,
10, groupsCount,
{ students,
averageLevel: -1, latestStudents,
}, latestTeachers,
{ id: 1, name: 1, email: 1, profilePicture: 1 } ] = await Promise.all([
); getEntities(undefined, { _id: 0, id: 1, label: 1 }),
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
const usersCount = await countUsersByTypes([ countGroups(),
"student", getUsers(
"teacher", { type: "student" },
"corporate", 10,
"mastercorporate", {
averageLevel: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
]); ]);
const latestStudents = await getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments( const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"), mapBy(entities, "id"),
{ archived: { $ne: true } } { archived: { $ne: true } }
); );
const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, "id")); const stats = await getStatsByUsers(mapBy(students, "id"));
return { return {

View File

@@ -14,10 +14,7 @@ import {
groupAllowedEntitiesByPermissions, groupAllowedEntitiesByPermissions,
} from "@/utils/permissions"; } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { import { countAllowedUsers, getUsers } from "@/utils/users.be";
countAllowedUsers,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
@@ -35,6 +32,7 @@ import {
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { count } from "console";
interface Props { interface Props {
user: User; user: User;
@@ -68,40 +66,44 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
"view_teachers", "view_teachers",
]); ]);
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id"); const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
const entitiesIDS = mapBy(entities, "id") || []; const entitiesIDS = mapBy(entities, "id") || [];
const [
const students = await getUsers( students,
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, latestStudents,
10, latestTeachers,
{ averageLevel: -1 }, userCounts,
{ id: 1, name: 1, email: 1, profilePicture: 1 } assignmentsCount,
); groupsCount,
const latestStudents = await getUsers( ] = await Promise.all([
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, getUsers(
10, { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
{ registrationDate: -1 }, 10,
{ id: 1, name: 1, email: 1, profilePicture: 1 } { averageLevel: -1 },
); { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
const latestTeachers = await getUsers( ),
{ getUsers(
type: "teacher", { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") }, 10,
}, { registrationDate: -1 },
10, { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
{ registrationDate: -1 }, ),
{ id: 1, name: 1, email: 1, profilePicture: 1 } getUsers(
); {
type: "teacher",
const userCounts = await countAllowedUsers(user, entities); "entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
const assignmentsCount = await countEntitiesAssignments( 10,
entitiesIDS, { registrationDate: -1 },
{ archived: { $ne: true } } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
countAllowedUsers(user, entities),
const groupsCount = await countGroupsByEntities(entitiesIDS); countEntitiesAssignments(entitiesIDS, {
archived: { $ne: true },
}),
countGroupsByEntities(entitiesIDS),
]);
return { return {
props: serialize({ props: serialize({
@@ -135,7 +137,7 @@ export default function Dashboard({
userCounts.student + userCounts.student +
userCounts.teacher, userCounts.teacher,
[userCounts] [userCounts]
); );
const totalLicenses = useMemo( const totalLicenses = useMemo(
() => () =>

View File

@@ -2,21 +2,16 @@
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, Type, User } from "@/interfaces/user"; import { Stat, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import { countEntitiesAssignments } from "@/utils/assignments.be";
countEntitiesAssignments,
} from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be"; import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be"; import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { import { countUsersByTypes, getUsers } from "@/utils/users.be";
countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -49,45 +44,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer"])) return redirect("/"); if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const students = await getUsers( const [
{ type: "student" }, students,
10, latestStudents,
{ latestTeachers,
averageLevel: -1, usersCount,
}, entities,
{ id: 1, name: 1, email: 1, profilePicture: 1 } groupsCount,
); ] = await Promise.all([
getUsers(
const usersCount = await countUsersByTypes([ { type: "student" },
"student", 10,
"teacher", { averageLevel: -1 },
"corporate", { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
"mastercorporate", ),
getUsers(
{ type: "student" },
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "teacher" },
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
countGroups(),
]); ]);
const latestStudents = await getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{id:1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ id:1,name: 1, email: 1, profilePicture: 1 }
);
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments( const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"), mapBy(entities, "id"),
{ archived: { $ne: true } } { archived: { $ne: true } }
); );
const groupsCount = await countGroups();
return { return {
props: serialize({ props: serialize({

View File

@@ -34,6 +34,7 @@ import {
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { count } from "console";
interface Props { interface Props {
user: User; user: User;
@@ -70,37 +71,39 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entitiesIDS = mapBy(entities, "id") || []; const entitiesIDS = mapBy(entities, "id") || [];
const students = await getUsers( const [
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, students,
10, latestStudents,
{ averageLevel: -1 }, latestTeachers,
{ id: 1, name: 1, email: 1, profilePicture: 1 } userCounts,
); assignmentsCount,
groupsCount,
const latestStudents = await getUsers( ] = await Promise.all([
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, getUsers(
10, { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
{ registrationDate: -1 }, 10,
{ id: 1, name: 1, email: 1, profilePicture: 1 } { averageLevel: -1 },
); { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
const latestTeachers = await getUsers( getUsers(
{ { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
type: "teacher", 10,
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") }, { registrationDate: -1 },
}, { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
10, ),
{ registrationDate: -1 }, getUsers(
{ id: 1, name: 1, email: 1, profilePicture: 1 } {
); type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
const userCounts = await countAllowedUsers(user, entities); },
10,
const assignmentsCount = await countEntitiesAssignments(entitiesIDS, { { registrationDate: -1 },
archived: { $ne: true }, { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
}); ),
countAllowedUsers(user, entities),
const groupsCount = await countGroupsByEntities(entitiesIDS); countEntitiesAssignments(entitiesIDS, { archived: { $ne: true } }),
countGroupsByEntities(entitiesIDS),
]);
return { return {
props: serialize({ props: serialize({
@@ -127,6 +130,7 @@ export default function Dashboard({
stats = [], stats = [],
groupsCount, groupsCount,
}: Props) { }: Props) {
const totalCount = useMemo( const totalCount = useMemo(
() => () =>
userCounts.corporate + userCounts.corporate +

View File

@@ -5,7 +5,7 @@ import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
import ModuleBadge from "@/components/ModuleBadge"; import ModuleBadge from "@/components/ModuleBadge";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Grading } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite"; import { InviteWithEntity } from "@/interfaces/invite";
@@ -34,6 +34,7 @@ import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react";
import { import {
BsBook, BsBook,
BsClipboard, BsClipboard,
@@ -65,42 +66,49 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return redirect("/"); return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntities(entityIDS, { _id: 0, label: 1 });
const currentDate = moment().toISOString(); const currentDate = moment().toISOString();
const assignments = await getAssignmentsForStudent(user.id, currentDate);
const stats = await getDetailedStatsByUser(user.id, "stats");
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 assignmentsIDs = mapBy(assignments, "id");
const sessions = await getSessionsByUser(user.id, 10, { const sessions = await getSessionsByUser(user.id, 10, {
["assignment.id"]: { $in: assignmentsIDs }, ["assignment.id"]: { $in: assignmentsIDs },
}); });
const invites = await getInvitesByInvitee(user.id);
const grading = await getGradingSystemByEntity(entityIDS[0] || "", {
_id: 0,
steps: 1,
});
const formattedInvites = await Promise.all( const formattedInvites = await Promise.all(
invites.map(convertInvitersToEntity) invites.map(convertInvitersToEntity)
); );
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.reduce<{ module: Module; id: string; key: string }[]>(
a.exams.map((e: { module: string; id: string }) => ({ (acc, a) => {
module: e.module, a.exams.forEach((e: { module: Module; id: string }) => {
id: e.id, acc.push({
key: `${e.module}_${e.id}`, module: e.module,
})) id: e.id,
key: `${e.module}_${e.id}`,
});
});
return acc;
},
[]
), ),
"key" "key"
); );
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : []; const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
return { return {
props: serialize({ props: serialize({
user, user,
entities,
assignments, assignments,
stats, stats,
exams, exams,
@@ -145,6 +153,11 @@ export default function Dashboard({
} }
}; };
const entitiesLabels = useMemo(
() => (entities.length > 0 ? mapBy(entities, "label")?.join(", ") : ""),
[entities]
);
return ( return (
<> <>
<Head> <Head>
@@ -160,7 +173,7 @@ export default function Dashboard({
<> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="rounded-lg bg-neutral-200 px-2 py-1 "> <div className="rounded-lg bg-neutral-200 px-2 py-1 ">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entitiesLabels}</b>
</div> </div>
)} )}

View File

@@ -27,6 +27,7 @@ import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { useMemo } from "react";
interface Props { interface Props {
user: User; user: User;
@@ -52,29 +53,29 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const filteredEntities = findAllowedEntities(user, entities, "view_students"); const filteredEntities = findAllowedEntities(user, entities, "view_students");
const students = await getEntitiesUsers( const [students, assignments, groups] = await Promise.all([
mapBy(filteredEntities, "id"), getEntitiesUsers(
{ mapBy(filteredEntities, "id"),
type: "student", {
}, type: "student",
0, },
{ 0,
_id: 0, {
id: 1, _id: 0,
name: 1, id: 1,
email: 1, name: 1,
profilePicture: 1, email: 1,
levels: 1, profilePicture: 1,
registrationDate: 1, levels: 1,
} registrationDate: 1,
); }
),
const assignments = await getEntitiesAssignments(entityIDS); getEntitiesAssignments(entityIDS),
getGroupsByEntities(entityIDS),
]);
const stats = await getStatsByUsers(students.map((u) => u.id)); const stats = await getStatsByUsers(students.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return { return {
props: serialize({ user, students, entities, assignments, stats, groups }), props: serialize({ user, students, entities, assignments, stats, groups }),
}; };
@@ -100,6 +101,10 @@ export default function Dashboard({
entities, entities,
"view_student_performance" "view_student_performance"
); );
const entitiesLabels = useMemo(
() => mapBy(entities, "label")?.join(", "),
[entities]
);
return ( return (
<> <>
@@ -117,7 +122,7 @@ export default function Dashboard({
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
{entities.length > 0 && ( {entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1"> <div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entitiesLabels}</b>
</div> </div>
)} )}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center"> <section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import { USER_TYPE_LABELS } from "@/resources/user";
import { redirect, serialize } from "@/utils"; import { redirect, serialize } from "@/utils";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName } from "@/utils/users"; import { getUserName } from "@/utils/users";
import { getUsers } from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
@@ -20,161 +20,218 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useState } from "react"; import { useState } from "react";
import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs"; import {
BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
if (!["admin", "developer"].includes(user.type)) return redirect("/entities") if (!["admin", "developer"].includes(user.type)) return redirect("/entities");
const users = await getUsers() const users = await getUsers(
{ id: { $ne: user.id } },
0,
{},
{
_id: 0,
id: 1,
name: 1,
type: 1,
profilePicture: 1,
email: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
}
);
return { return {
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }), props: serialize({ user, users }),
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
} }
export default function Home({ user, users }: Props) { export default function Home({ user, users }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [licenses, setLicenses] = useState(0); const [licenses, setLicenses] = useState(0);
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users); const { rows, renderSearch } = useListSearch<User>(
const { items, renderMinimal } = usePagination<User>(rows, 16); [["name"], ["corporateInformation", "companyInformation", "name"]],
users
);
const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter(); const router = useRouter();
const createGroup = () => { const createGroup = () => {
if (!label.trim()) return; if (!label.trim()) return;
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return; if (
!confirm(
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers }) .post<Entity>(`/api/entities`, {
.then((result) => { label,
toast.success("Your entity has been created successfully!"); licenses,
router.replace(`/entities/${result.data.id}`); members: selectedUsers,
}) })
.catch((e) => { .then((result) => {
console.error(e); toast.success("Your entity has been created successfully!");
toast.error("Something went wrong!"); router.replace(`/entities/${result.data.id}`);
}) })
.finally(() => setIsLoading(false)); .catch((e) => {
}; console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
return ( return (
<> <>
<Head> <Head>
<title>Create Entity | EnCoach</title> <title>Create Entity | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between"> <div className="flex gap-3 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
<BsChevronLeft /> >
</Link> <BsChevronLeft />
<h2 className="font-bold text-2xl">Create Entity</h2> </Link>
</div> <h2 className="font-bold text-2xl">Create Entity</h2>
<div className="flex items-center gap-4"> </div>
<button <div className="flex items-center gap-4">
onClick={createGroup} <button
disabled={!label.trim() || licenses <= 0 || isLoading} onClick={createGroup}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> disabled={!label.trim() || licenses <= 0 || isLoading}
<BsCheck /> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
<span className="text-xs">Create Entity</span> >
</button> <BsCheck />
</div> <span className="text-xs">Create Entity</span>
</div> </button>
<Divider /> </div>
<div className="w-full grid grid-cols-2 gap-4"> </div>
<div className="flex flex-col gap-4 w-full"> <Divider />
<span className="font-semibold text-xl">Entity Label:</span> <div className="w-full grid grid-cols-2 gap-4">
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" /> <div className="flex flex-col gap-4 w-full">
</div> <span className="font-semibold text-xl">Entity Label:</span>
<Input
name="name"
onChange={setLabel}
type="text"
placeholder="Entity A"
/>
</div>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Licenses:</span> <span className="font-semibold text-xl">Licenses:</span>
<Input name="licenses" min={0} onChange={(v) => setLicenses(parseInt(v))} type="number" placeholder="12" /> <Input
</div> name="licenses"
</div> min={0}
<Divider /> onChange={(v) => setLicenses(parseInt(v))}
<div className="flex items-center justify-between mb-4"> type="number"
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span> placeholder="12"
</div> />
<div className="w-full flex items-center gap-4"> </div>
{renderSearch()} </div>
{renderMinimal()} <Divider />
</div> <div className="flex items-center justify-between mb-4">
</section> <span className="font-semibold text-xl">
Members ({selectedUsers.length} selected):
</span>
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
</section>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => ( {items.map((u) => (
<button <button
onClick={() => toggleUser(u)} onClick={() => toggleUser(u)}
disabled={isLoading} disabled={isLoading}
key={u.id} key={u.id}
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
<div className="flex items-center gap-2"> >
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="flex items-center gap-2">
<img src={u.profilePicture} alt={u.name} /> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
</div> <img src={u.profilePicture} alt={u.name} />
<div className="flex flex-col"> </div>
<span className="font-semibold">{getUserName(u)}</span> <div className="flex flex-col">
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="font-semibold">{getUserName(u)}</span>
</div> <span className="opacity-80 text-sm">
</div> {USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address"> <Tooltip tooltip="E-mail address">
<BsEnvelopeFill /> <BsEnvelopeFill />
</Tooltip> </Tooltip>
{u.email} {u.email}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
</span> ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
<span className="flex items-center gap-2"> : "Unlimited"}
<Tooltip tooltip="Last Login"> </span>
<BsClockFill /> <span className="flex items-center gap-2">
</Tooltip> <Tooltip tooltip="Last Login">
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} <BsClockFill />
</span> </Tooltip>
</div> {u.lastLogin
</button> ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
))} : "N/A"}
</section> </span>
</> </div>
</> </button>
); ))}
</section>
</>
</>
);
} }

View File

@@ -35,17 +35,35 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
); );
const allowedEntities = findAllowedEntities(user, entities, "view_entities"); const allowedEntities = findAllowedEntities(user, entities, "view_entities");
const entitiesWithCount = await Promise.all( const [counts, users] = await Promise.all([
allowedEntities.map(async (e) => ({ await Promise.all(
entity: e, allowedEntities.map(async (e) =>
count: await countEntityUsers(e.id, { countEntityUsers(e.id, {
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] }, type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
}), })
users: await getEntityUsers(e.id, 5, { )
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] }, ),
}), await Promise.all(
})) allowedEntities.map(async (e) =>
); getEntityUsers(
e.id,
5,
{
type: {
$in: ["student", "teacher", "corporate", "mastercorporate"],
},
},
{ name: 1 }
)
)
),
]);
const entitiesWithCount = allowedEntities.map<{
entity: EntityWithRoles;
users: User[];
count: number;
}>((e, i) => ({ entity: e, users: users[i], count: counts[i] }));
return { return {
props: serialize({ user, entities: entitiesWithCount }), props: serialize({ user, entities: entitiesWithCount }),

View File

@@ -21,93 +21,124 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { activeAssignmentFilter } from "@/utils/assignments"; import { activeAssignmentFilter } from "@/utils/assignments";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, query }) => {
const loginDestination = Buffer.from(req.url || "/").toString("base64") const user = await requestUser(req, res);
if (!user) return redirect(`/login?destination=${loginDestination}`) const loginDestination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${loginDestination}`);
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string } const { assignment: assignmentID, destination } = query as {
const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined assignment?: string;
destination?: string;
};
const destinationURL = !!destination
? Buffer.from(destination, "base64").toString()
: undefined;
if (!!assignmentID) { if (!!assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect(destinationURL || "/exam") if (!assignment) return redirect(destinationURL || "/exam");
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type)) if (
return redirect(destinationURL || "/exam") !assignment.assignees.includes(user.id) &&
!["admin", "developer"].includes(user.type)
)
return redirect(destinationURL || "/exam");
if (filterBy(assignment.results, 'user', user.id).length > 0) if (filterBy(assignment.results, "user", user.id).length > 0)
return redirect(destinationURL || "/exam") return redirect(destinationURL || "/exam");
const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) const [exams, session] = await Promise.all([
const session = await getSessionByAssignment(assignmentID) getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
return { return {
props: serialize({ user, assignment, exams, destinationURL, session: session ?? undefined }) props: serialize({
} user,
} assignment,
exams,
destinationURL,
session: session ?? undefined,
}),
};
}
return { return {
props: serialize({ user, destinationURL }), props: serialize({ user, destinationURL }),
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment assignment?: Assignment;
exams?: Exam[] exams?: Exam[];
session?: Session session?: Session;
destinationURL?: string destinationURL?: string;
} }
const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => { const Page: React.FC<Props> = ({
const router = useRouter() user,
const { assignment: storeAssignment, dispatch } = useExamStore(); assignment,
exams = [],
destinationURL = "/exam",
session,
}) => {
const router = useRouter();
const { assignment: storeAssignment, dispatch } = useExamStore();
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
if (!activeAssignmentFilter(assignment)) return if (!activeAssignmentFilter(assignment)) return;
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
exams: exams.sort(sortByModule), payload: {
modules: exams exams: exams.sort(sortByModule),
.map((x) => x!) modules: exams
.sort(sortByModule) .map((x) => x!)
.map((x) => x!.module), .sort(sortByModule)
assignment .map((x) => x!.module),
} assignment,
}); },
});
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } }) dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
return ( return (
<> <>
<Head> <Head>
<title>Exams | EnCoach</title> <title>Exams | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!storeAssignment} /> <ExamPage
</> page="exams"
); destination={destinationURL}
} user={user}
hideSidebar={!!assignment || !!storeAssignment}
/>
</>
);
};
//Page.whyDidYouRender = true; //Page.whyDidYouRender = true;
export default Page; export default Page;

View File

@@ -21,92 +21,100 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import moment from "moment"; import moment from "moment";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, query }) => {
const destination = Buffer.from(req.url || "/").toString("base64") const user = await requestUser(req, res);
if (!user) return redirect(`/login?destination=${destination}`) const destination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${destination}`);
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { assignment: assignmentID } = query as { assignment?: string } const { assignment: assignmentID } = query as { assignment?: string };
if (assignmentID) { if (assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect("/exam") if (!assignment) return redirect("/exam");
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises") if (
!["admin", "developer"].includes(user.type) &&
!assignment.assignees.includes(user.id)
)
return redirect("/exercises");
if (
filterBy(assignment.results, "user", user.id) ||
moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment())
)
return redirect("/exam");
const [exams, session] = await Promise.all([
getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) return {
const session = await getSessionByAssignment(assignmentID) props: serialize({ user, assignment, exams, session }),
};
}
if ( return {
filterBy(assignment.results, 'user', user.id) || props: serialize({ user }),
moment(assignment.startDate).isBefore(moment()) || };
moment(assignment.endDate).isAfter(moment()) },
) sessionOptions
return redirect("/exam") );
return {
props: serialize({ user, assignment, exams, session })
}
}
return {
props: serialize({ user }),
};
}, sessionOptions);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment assignment?: Assignment;
exams?: Exam[] exams?: Exam[];
session?: Session session?: Session;
} }
export default function Page({ user, assignment, exams = [], session }: Props) { export default function Page({ user, assignment, exams = [], session }: Props) {
const router = useRouter() const router = useRouter();
const { assignment: storeAssignment, dispatch } = useExamStore() const { assignment: storeAssignment, dispatch } = useExamStore();
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
exams: exams.sort(sortByModule), payload: {
modules: exams exams: exams.sort(sortByModule),
.map((x) => x!) modules: exams
.sort(sortByModule) .map((x) => x!)
.map((x) => x!.module), .sort(sortByModule)
assignment .map((x) => x!.module),
} assignment,
}) },
});
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } }); dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
return ( return (
<> <>
<Head> <Head>
<title>Exams | EnCoach</title> <title>Exams | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exams" user={user} hideSidebar={!!assignment} /> <ExamPage page="exams" user={user} hideSidebar={!!assignment} />
</> </>
); );
} }

View File

@@ -4,7 +4,7 @@ import Button from "@/components/Low/Button";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Grading } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite"; import { InviteWithEntity } from "@/interfaces/invite";
@@ -12,14 +12,13 @@ import { Assignment } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import {
activeAssignmentFilter, activeAssignmentFilter,
futureAssignmentFilter, futureAssignmentFilter,
} from "@/utils/assignments"; } from "@/utils/assignments";
import { getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
@@ -53,32 +52,59 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer", "student"])) if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/"); return redirect("/");
const assignments = (await getAssignmentsByAssignee(
user.id,
{
archived: { $ne: true },
},
{
_id: 0,
id: 1,
name: 1,
startDate: 1,
endDate: 1,
exams: 1,
results: 1,
},
{
sort: { startDate: 1 },
}
)) as Assignment[];
const entityIDS = mapBy(user.entities, "id") || []; const sessions = await getSessionsByUser(
user.id,
const entities = await getEntitiesWithRoles(entityIDS); 0,
const assignments = await getAssignmentsByAssignee(user.id, { {
archived: { $ne: true }, "assignment.id": { $in: mapBy(assignments, "id") },
}); },
const sessions = await getSessionsByUser(user.id, 0, { {
"assignment.id": { $in: mapBy(assignments, "id") }, _id: 0,
}); id: 1,
assignment: 1,
}
);
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.reduce<{ module: Module; id: string; key: string }[]>(
filterBy(a.exams, "assignee", user.id).map( (acc, a) => {
(e: any) => ({ a.exams.forEach((e) => {
module: e.module, if (e.assignee === user.id)
id: e.id, acc.push({
key: `${e.module}_${e.id}`, module: e.module,
}) id: e.id,
) key: `${e.module}_${e.id}`,
});
});
return acc;
},
[]
), ),
"key" "key"
); );
const exams = await getExamsByIds(examIDs); const exams = await getExamsByIds(examIDs);
return { props: serialize({ user, entities, assignments, exams, sessions }) }; return { props: serialize({ user, assignments, exams, sessions }) };
}, sessionOptions); }, sessionOptions);
const destination = Buffer.from("/official-exam").toString("base64"); const destination = Buffer.from("/official-exam").toString("base64");
@@ -109,11 +135,12 @@ export default function OfficialExam({
}); });
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
const sortedAssignmentExams = assignmentExams.sort(sortByModule);
dispatch({ dispatch({
type: "INIT_EXAM", type: "INIT_EXAM",
payload: { payload: {
exams: assignmentExams.sort(sortByModule), exams: sortedAssignmentExams,
modules: mapBy(assignmentExams.sort(sortByModule), "module"), modules: mapBy(sortedAssignmentExams, "module"),
assignment, assignment,
}, },
}); });
@@ -144,12 +171,16 @@ export default function OfficialExam({
[assignments] [assignments]
); );
const assignmentSessions = useMemo( const assignmentSessions = useMemo(() => {
() => const studentAssignmentsIDs = mapBy(studentAssignments, "id");
sessions.filter((s) => return sessions.filter((s) =>
mapBy(studentAssignments, "id").includes(s.assignment?.id || "") studentAssignmentsIDs.includes(s.assignment?.id || "")
), );
[sessions, studentAssignments] }, [sessions, studentAssignments]);
const entityLabels = useMemo(
() => mapBy(entities, "label")?.join(","),
[entities]
); );
return ( return (
@@ -167,7 +198,7 @@ export default function OfficialExam({
<> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> <div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entityLabels}</b>
</div> </div>
)} )}
@@ -191,20 +222,18 @@ export default function OfficialExam({
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && {studentAssignments.length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."} "Assignments will appear here. It seems that for now there are no assignments for you."}
{studentAssignments {studentAssignments.map((a) => (
.sort((a, b) => moment(a.startDate).diff(b.startDate)) <AssignmentCard
.map((a) => ( key={a.id}
<AssignmentCard assignment={a}
key={a.id} user={user}
assignment={a} session={assignmentSessions.find(
user={user} (s) => s.assignment?.id === a.id
session={assignmentSessions.find( )}
(s) => s.assignment?.id === a.id startAssignment={startAssignment}
)} resumeAssignment={loadSession}
startAssignment={startAssignment} />
resumeAssignment={loadSession} ))}
/>
))}
</span> </span>
</section> </section>

View File

@@ -31,7 +31,7 @@ import { useListSearch } from "@/hooks/useListSearch";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity, EntityWithRoles } from "@/interfaces/entity";

View File

@@ -21,11 +21,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user) return redirect("/login") if (!user) return redirect("/login")
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const domain = user.email.split("@").pop() const domain = user.email.split("@").pop()
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray() const [entities, discounts, packages] = await Promise.all([
const packages = await db.collection<Package>("packages").find().toArray() getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs),
db.collection<Discount>("discounts").find({ domain }).toArray(),
db.collection<Package>("packages").find().toArray(),
])
return { return {
props: serialize({ user, entities, discounts, packages }), props: serialize({ user, entities, discounts, packages }),

View File

@@ -7,7 +7,7 @@ import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission, PermissionType } from "@/interfaces/permissions"; import { Permission, PermissionType } from "@/interfaces/permissions";
import { getPermissionDoc } from "@/utils/permissions.be"; import { getPermissionDoc } from "@/utils/permissions.be";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { LayoutContext } from "@/components/High/Layout"; import { LayoutContext } from "@/components/High/Layout";
import { getUsers } from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import { BsTrash } from "react-icons/bs"; import { BsTrash } from "react-icons/bs";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
@@ -18,6 +18,7 @@ import { Type as UserType } from "@/interfaces/user";
import { getGroups } from "@/utils/groups.be"; import { getGroups } from "@/utils/groups.be";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
import { G } from "@react-pdf/renderer";
interface BasicUser { interface BasicUser {
id: string; id: string;
name: string; name: string;
@@ -40,31 +41,25 @@ export const getServerSideProps = withIronSessionSsr(
if (!params?.id) return redirect("/permissions"); if (!params?.id) return redirect("/permissions");
// Fetch data from external API // Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string); const [permission, users, groups] = await Promise.all([
getPermissionDoc(params.id as string),
const allUserData: User[] = await getUsers(); getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
const groups = await getGroups(); getGroups(),
]);
const userGroups = groups.filter((x) => x.admin === user.id); const userGroups = groups.filter((x) => x.admin === user.id);
const userGroupsParticipants = userGroups.flatMap((x) => x.participants);
const filteredGroups = const filteredGroups =
user.type === "corporate" user.type === "corporate"
? userGroups ? userGroups
: user.type === "mastercorporate" : user.type === "mastercorporate"
? groups.filter((x) => ? groups.filter((x) => userGroupsParticipants.includes(x.admin))
userGroups.flatMap((y) => y.participants).includes(x.admin)
)
: groups; : groups;
const filteredGroupsParticipants = filteredGroups.flatMap(
const users = allUserData.map((u) => ({ (g) => g.participants
id: u.id, );
name: u.name,
type: u.type,
})) as BasicUser[];
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type) const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) => ? users.filter((u) => filteredGroupsParticipants.includes(u.id))
filteredGroups.flatMap((g) => g.participants).includes(u.id)
)
: users; : users;
// const res = await fetch("api/permissions"); // const res = await fetch("api/permissions");
@@ -158,12 +153,14 @@ export default function Page(props: Props) {
<div className="flex gap-3"> <div className="flex gap-3">
<Select <Select
value={null} value={null}
options={users options={users.reduce<{ label: string; value: string }[]>(
.filter((u) => !selectedUsers.includes(u.id)) (acc, u) => {
.map((u) => ({ if (!selectedUsers.includes(u.id))
label: `${u?.type}-${u?.name}`, acc.push({ label: `${u?.type}-${u?.name}`, value: u.id });
value: u.id, return acc;
}))} },
[]
)}
onChange={onChange} onChange={onChange}
/> />
<Button onClick={update}>Update</Button> <Button onClick={update}>Update</Button>
@@ -195,9 +192,8 @@ export default function Page(props: Props) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2>Whitelisted Users</h2> <h2>Whitelisted Users</h2>
<div className="flex flex-col gap-3 flex-wrap"> <div className="flex flex-col gap-3 flex-wrap">
{users {users.map((user) => {
.filter((user) => !selectedUsers.includes(user.id)) if (!selectedUsers.includes(user.id))
.map((user) => {
return ( return (
<div <div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
@@ -208,7 +204,8 @@ export default function Page(props: Props) {
</span> </span>
</div> </div>
); );
})} return null;
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -53,23 +53,23 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user) return redirect("/login"); if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/"); if (shouldRedirectHome(user)) return redirect("/");
const linkedCorporate = (await getUserCorporate(user.id)) || null; const [linkedCorporate, groups, referralAgent] = await Promise.all([
const groups = ( getUserCorporate(user.id) || null,
await getParticipantGroups(user.id, { _id: 0, admin: 1 }) getParticipantGroups(user.id, { _id: 0, group: 1 }),
).map((group) => group.admin);
const referralAgent =
user.type === "corporate" && user.corporateInformation.referralAgent user.type === "corporate" && user.corporateInformation.referralAgent
? await getUser(user.corporateInformation.referralAgent, { ? getUser(user.corporateInformation.referralAgent, {
_id: 0, _id: 0,
name: 1, name: 1,
email: 1, email: 1,
demographicInformation: 1, demographicInformation: 1,
}) })
: null; : null,
]);
const groupsAdmin = groups.map((group) => group.admin);
const hasBenefitsFromUniversity = const hasBenefitsFromUniversity =
(await countUsers({ (await countUsers({
id: { $in: groups }, id: { $in: groupsAdmin },
type: "corporate", type: "corporate",
})) > 0; })) > 0;

View File

@@ -23,7 +23,9 @@ import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be"; import {
getGradingSystemByEntities,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces"; import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
@@ -33,208 +35,272 @@ import getPendingEvals from "@/utils/disabled.be";
import useEvaluationPolling from "@/hooks/useEvaluationPolling"; import useEvaluationPolling from "@/hooks/useEvaluationPolling";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, "id");
const isAdmin = checkAccess(user, ["admin", "developer"]) const isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) const entities = await getEntitiesWithRoles(isAdmin ? undefined : entityIDs);
const entitiesIds = mapBy(entities, 'id') const entitiesIds = mapBy(entities, "id");
const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds)) const [users, assignments, gradingSystems, pendingSessionIds] =
const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds)) await Promise.all([
const gradingSystems = await getGradingSystemByEntities(entitiesIds) isAdmin ? getUsers() : getEntitiesUsers(entitiesIds),
const pendingSessionIds = await getPendingEvals(user.id); isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds),
getGradingSystemByEntities(entitiesIds),
getPendingEvals(user.id),
]);
return { return {
props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }), props: serialize({
}; user,
users,
assignments,
entities,
gradingSystems,
isAdmin,
pendingSessionIds,
}),
};
}, sessionOptions); }, sessionOptions);
type Filter = "months" | "weeks" | "days" | "assignments" | undefined; type Filter = "months" | "weeks" | "days" | "assignments" | undefined;
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
gradingSystems: Grading[] gradingSystems: Grading[];
pendingSessionIds: string[]; pendingSessionIds: string[];
isAdmin:boolean isAdmin: boolean;
} }
const MAX_TRAINING_EXAMS = 10; const MAX_TRAINING_EXAMS = 10;
export default function History({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }: Props) { export default function History({
const router = useRouter(); user,
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ users,
state.selectedUser, assignments,
state.setSelectedUser, entities,
state.training, gradingSystems,
state.setTraining, isAdmin,
]); pendingSessionIds,
}: Props) {
const router = useRouter();
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore(
(state) => [
state.selectedUser,
state.setSelectedUser,
state.training,
state.setTraining,
]
);
const [filter, setFilter] = useState<Filter>(); const [filter, setFilter] = useState<Filter>();
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id); const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record') Stat[]
>(statsUserId || user?.id);
const allowedDownloadEntities = useAllowedEntities(
user,
entities,
"download_student_record"
);
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]); const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>(
const setTrainingStats = useTrainingContentStore((state) => state.setStats); []
);
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
const groupedStats = useMemo(() => groupByDate( const groupedStats = useMemo(
stats.filter((x) => { () =>
if ( groupByDate(
( stats.filter((x) => {
x.module === "writing" || x.module === "speaking") && if (
!x.isDisabled && Array.isArray(x.solutions) && (x.module === "writing" || x.module === "speaking") &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation") !x.isDisabled &&
) Array.isArray(x.solutions) &&
) !x.solutions.every((y) => Object.keys(y).includes("evaluation"))
return false; )
return true; return false;
}), return true;
), [stats]) })
),
[stats]
);
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]); useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
useEffect(() => { useEffect(() => {
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
setTraining(false); setTraining(false);
}; };
router.events.on("routeChangeStart", handleRouteChange); router.events.on("routeChangeStart", handleRouteChange);
return () => { return () => {
router.events.off("routeChangeStart", handleRouteChange); router.events.off("routeChangeStart", handleRouteChange);
}; };
}, [router.events, setTraining]); }, [router.events, setTraining]);
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => { const filterStatsByDate = (stats: { [key: string]: Stat[] }) => {
if (filter && filter !== "assignments") { if (filter && filter !== "assignments") {
const filterDate = moment() const filterDate = moment()
.subtract({ [filter as string]: 1 }) .subtract({ [filter as string]: 1 })
.format("x"); .format("x");
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp]; if (timestamp >= filterDate)
}); filteredStats[timestamp] = stats[timestamp];
return filteredStats; });
} return filteredStats;
}
if (filter && filter === "assignments") { if (filter && filter === "assignments") {
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false)) if (
filteredStats[timestamp] = [...stats[timestamp].filter((s) => !!s.assignment)]; stats[timestamp]
}); .map((s) => s.assignment === undefined)
.includes(false)
)
filteredStats[timestamp] = [
...stats[timestamp].filter((s) => !!s.assignment),
];
});
return filteredStats; return filteredStats;
} }
return stats; return stats;
}; };
const handleTrainingContentSubmission = () => { const handleTrainingContentSubmission = () => {
if (groupedStats) { if (groupedStats) {
const groupedStatsByDate = filterStatsByDate(groupedStats); const groupedStatsByDate = filterStatsByDate(groupedStats);
const allStats = Object.keys(groupedStatsByDate); const allStats = Object.keys(groupedStatsByDate);
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => { const selectedStats = selectedTrainingExams.reduce<
const timestamp = moduleAndTimestamp.split("-")[1]; Record<string, Stat[]>
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) { >((accumulator, moduleAndTimestamp) => {
accumulator[timestamp] = groupedStatsByDate[timestamp]; const timestamp = moduleAndTimestamp.split("-")[1];
} if (
return accumulator; allStats.includes(timestamp) &&
}, {}); !accumulator.hasOwnProperty(timestamp)
setTrainingStats(Object.values(selectedStats).flat()); ) {
router.push("/training"); accumulator[timestamp] = groupedStatsByDate[timestamp];
} }
}; return accumulator;
}, {});
setTrainingStats(Object.values(selectedStats).flat());
router.push("/training");
}
};
const filteredStats = useMemo(() => const filteredStats = useMemo(
Object.keys(filterStatsByDate(groupedStats)) () =>
.sort((a, b) => parseInt(b) - parseInt(a)), Object.keys(filterStatsByDate(groupedStats)).sort(
// eslint-disable-next-line react-hooks/exhaustive-deps (a, b) => parseInt(b) - parseInt(a)
[groupedStats, filter]) ),
// eslint-disable-next-line react-hooks/exhaustive-deps
[groupedStats, filter]
);
const customContent = (timestamp: string) => { const customContent = (timestamp: string) => {
const dateStats = groupedStats[timestamp]; const dateStats = groupedStats[timestamp];
const statUser = findBy(users, 'id', dateStats[0]?.user) const statUser = findBy(users, "id", dateStats[0]?.user);
const canDownload = mapBy(statUser?.entities, 'id').some(e => mapBy(allowedDownloadEntities, 'id').includes(e)) const canDownload = mapBy(statUser?.entities, "id").some((e) =>
mapBy(allowedDownloadEntities, "id").includes(e)
);
return ( return (
<StatsGridItem <StatsGridItem
key={uuidv4()} key={uuidv4()}
stats={dateStats} stats={dateStats}
gradingSystems={gradingSystems} gradingSystems={gradingSystems}
timestamp={timestamp} timestamp={timestamp}
user={user} user={user}
assignments={assignments} assignments={assignments}
users={users} users={users}
training={training} training={training}
selectedTrainingExams={selectedTrainingExams} selectedTrainingExams={selectedTrainingExams}
setSelectedTrainingExams={setSelectedTrainingExams} setSelectedTrainingExams={setSelectedTrainingExams}
maxTrainingExams={MAX_TRAINING_EXAMS} maxTrainingExams={MAX_TRAINING_EXAMS}
renderPdfIcon={canDownload ? renderPdfIcon : undefined} renderPdfIcon={canDownload ? renderPdfIcon : undefined}
/> />
); );
}; };
useEvaluationPolling(pendingSessionIds ? pendingSessionIds : [], "records", user.id); useEvaluationPolling(
pendingSessionIds ? pendingSessionIds : [],
"records",
user.id
);
return ( return (
<> <>
<Head> <Head>
<title>Record | EnCoach</title> <title>Record | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<> <>
<RecordFilter user={user} isAdmin={isAdmin} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}> <RecordFilter
{training && ( user={user}
<div className="flex flex-row"> isAdmin={isAdmin}
<div className="font-semibold text-2xl mr-4"> entities={entities}
Select up to 10 exercises filterState={{ filter: filter, setFilter: setFilter }}
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`} >
</div> {training && (
<button <div className="flex flex-row">
className={clsx( <div className="font-semibold text-2xl mr-4">
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed", Select up to 10 exercises
"transition duration-300 ease-in-out", {`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
)} </div>
disabled={selectedTrainingExams.length == 0} <button
onClick={handleTrainingContentSubmission}> className={clsx(
Submit "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
</button> "transition duration-300 ease-in-out"
</div> )}
)} disabled={selectedTrainingExams.length == 0}
</RecordFilter> onClick={handleTrainingContentSubmission}
>
Submit
</button>
</div>
)}
</RecordFilter>
{filteredStats.length > 0 && !isStatsLoading && (
{filteredStats.length > 0 && !isStatsLoading && ( <CardList
<CardList list={filteredStats} renderCard={customContent} searchFields={[]} pageSize={30} className="lg:!grid-cols-3" /> list={filteredStats}
)} renderCard={customContent}
{filteredStats.length === 0 && !isStatsLoading && ( searchFields={[]}
<span className="font-semibold ml-1">No record to display...</span> pageSize={30}
)} className="lg:!grid-cols-3"
{isStatsLoading && ( />
<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" /> {filteredStats.length === 0 && !isStatsLoading && (
</div> <span className="font-semibold ml-1">No record to display...</span>
)} )}
</> {isStatsLoading && (
)} <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>
)}
</>
)}
</>
);
} }

View File

@@ -13,169 +13,256 @@ import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { useState } from "react"; import { useState } from "react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill } from "react-icons/bs"; import {
BsCode,
BsCodeSquare,
BsGearFill,
BsPeopleFill,
BsPersonFill,
} from "react-icons/bs";
import UserCreator from "./(admin)/UserCreator"; import UserCreator from "./(admin)/UserCreator";
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem"; import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
import { CEFR_STEPS } from "@/resources/grading"; import { CEFR_STEPS } from "@/resources/grading";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { getUserPermissions } from "@/utils/permissions.be"; import { getUserPermissions } from "@/utils/permissions.be";
import { PermissionType } from "@/interfaces/permissions"; import { PermissionType } from "@/interfaces/permissions";
import { getUsers } from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { mapBy, serialize, redirect } from "@/utils"; import { mapBy, serialize, redirect } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be"; import {
getGradingSystemByEntities,
getGradingSystemByEntity,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces"; import { Grading } from "@/interfaces";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) if (
return redirect("/") shouldRedirectHome(user) ||
!checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/");
const [permissions, entities, allUsers] = await Promise.all([
getUserPermissions(user.id),
isAdmin(user)
? await getEntitiesWithRoles()
: await getEntitiesWithRoles(mapBy(user.entities, "id")),
getUsers(),
]);
const gradingSystems = await getGradingSystemByEntities(
mapBy(entities, "id")
);
const entitiesGrading = entities.map(
(e) =>
gradingSystems.find((g) => g.entity === e.id) || {
entity: e.id,
steps: CEFR_STEPS,
}
);
const permissions = await getUserPermissions(user.id); return {
const entities = isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, 'id')) props: serialize({
const allUsers = await getUsers() user,
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id')) permissions,
const entitiesGrading = entities.map(e => gradingSystems.find(g => g.entity === e.id) || { entity: e.id, steps: CEFR_STEPS }) entities,
allUsers,
return { entitiesGrading,
props: serialize({ user, permissions, entities, allUsers, entitiesGrading }), }),
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
permissions: PermissionType[]; permissions: PermissionType[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
allUsers: User[] allUsers: User[];
entitiesGrading: Grading[] entitiesGrading: Grading[];
} }
export default function Admin({ user, entities, permissions, allUsers, entitiesGrading }: Props) { export default function Admin({
const [modalOpen, setModalOpen] = useState<string>(); user,
const router = useRouter() entities,
permissions,
allUsers,
entitiesGrading,
}: Props) {
const [modalOpen, setModalOpen] = useState<string>();
const router = useRouter();
const entitiesAllowCreateUser = useAllowedEntities(user, entities, 'create_user') const entitiesAllowCreateUser = useAllowedEntities(
const entitiesAllowCreateUsers = useAllowedEntities(user, entities, 'create_user_batch') user,
const entitiesAllowCreateCode = useAllowedEntities(user, entities, 'create_code') entities,
const entitiesAllowCreateCodes = useAllowedEntities(user, entities, 'create_code_batch') "create_user"
const entitiesAllowEditGrading = useAllowedEntities(user, entities, 'edit_grading_system') );
const entitiesAllowCreateUsers = useAllowedEntities(
user,
entities,
"create_user_batch"
);
const entitiesAllowCreateCode = useAllowedEntities(
user,
entities,
"create_code"
);
const entitiesAllowCreateCodes = useAllowedEntities(
user,
entities,
"create_code_batch"
);
const entitiesAllowEditGrading = useAllowedEntities(
user,
entities,
"edit_grading_system"
);
return ( return (
<> <>
<Head> <Head>
<title>Settings Panel | EnCoach</title> <title>Settings Panel | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]"> <Modal
<BatchCreateUser isOpen={modalOpen === "batchCreateUser"}
user={user} onClose={() => setModalOpen(undefined)}
entities={entitiesAllowCreateUser} maxWidth="max-w-[85%]"
permissions={permissions} >
onFinish={() => setModalOpen(undefined)} <BatchCreateUser
/> user={user}
</Modal> entities={entitiesAllowCreateUser}
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}> permissions={permissions}
<BatchCodeGenerator onFinish={() => setModalOpen(undefined)}
entities={entitiesAllowCreateCodes} />
user={user} </Modal>
users={allUsers} <Modal
permissions={permissions} isOpen={modalOpen === "batchCreateCode"}
onFinish={() => setModalOpen(undefined)} onClose={() => setModalOpen(undefined)}
/> >
</Modal> <BatchCodeGenerator
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}> entities={entitiesAllowCreateCodes}
<CodeGenerator user={user}
entities={entitiesAllowCreateCode} users={allUsers}
user={user} permissions={permissions}
permissions={permissions} onFinish={() => setModalOpen(undefined)}
onFinish={() => setModalOpen(undefined)} />
/> </Modal>
</Modal> <Modal
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}> isOpen={modalOpen === "createCode"}
<UserCreator onClose={() => setModalOpen(undefined)}
user={user} >
entities={entitiesAllowCreateUsers} <CodeGenerator
users={allUsers} entities={entitiesAllowCreateCode}
permissions={permissions} user={user}
onFinish={() => setModalOpen(undefined)} permissions={permissions}
/> onFinish={() => setModalOpen(undefined)}
</Modal> />
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}> </Modal>
<CorporateGradingSystem <Modal
user={user} isOpen={modalOpen === "createUser"}
entitiesGrading={entitiesGrading} onClose={() => setModalOpen(undefined)}
entities={entitiesAllowEditGrading} >
mutate={() => router.replace(router.asPath)} <UserCreator
/> user={user}
</Modal> entities={entitiesAllowCreateUsers}
users={allUsers}
permissions={permissions}
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal
isOpen={modalOpen === "gradingSystem"}
onClose={() => setModalOpen(undefined)}
>
<CorporateGradingSystem
user={user}
entitiesGrading={entitiesGrading}
entities={entitiesAllowEditGrading}
mutate={() => router.replace(router.asPath)}
/>
</Modal>
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8"> <section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader /> <ExamLoader />
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( {checkAccess(
<div className="w-full grid grid-cols-2 gap-4"> user,
<IconCard getTypesOfUser(["teacher"]),
Icon={BsCode} permissions,
label="Generate Single Code" "viewCodes"
color="purple" ) && (
className="w-full h-full" <div className="w-full grid grid-cols-2 gap-4">
onClick={() => setModalOpen("createCode")} <IconCard
disabled={entitiesAllowCreateCode.length === 0} Icon={BsCode}
/> label="Generate Single Code"
<IconCard color="purple"
Icon={BsCodeSquare} className="w-full h-full"
label="Generate Codes in Batch" onClick={() => setModalOpen("createCode")}
color="purple" disabled={entitiesAllowCreateCode.length === 0}
className="w-full h-full" />
onClick={() => setModalOpen("batchCreateCode")} <IconCard
disabled={entitiesAllowCreateCodes.length === 0} Icon={BsCodeSquare}
/> label="Generate Codes in Batch"
<IconCard color="purple"
Icon={BsPersonFill} className="w-full h-full"
label="Create Single User" onClick={() => setModalOpen("batchCreateCode")}
color="purple" disabled={entitiesAllowCreateCodes.length === 0}
className="w-full h-full" />
onClick={() => setModalOpen("createUser")} <IconCard
disabled={entitiesAllowCreateUser.length === 0} Icon={BsPersonFill}
/> label="Create Single User"
<IconCard color="purple"
Icon={BsPeopleFill} className="w-full h-full"
label="Create Users in Batch" onClick={() => setModalOpen("createUser")}
color="purple" disabled={entitiesAllowCreateUser.length === 0}
className="w-full h-full" />
onClick={() => setModalOpen("batchCreateUser")} <IconCard
disabled={entitiesAllowCreateUsers.length === 0} Icon={BsPeopleFill}
/> label="Create Users in Batch"
{checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( color="purple"
<IconCard className="w-full h-full"
Icon={BsGearFill} onClick={() => setModalOpen("batchCreateUser")}
label="Grading System" disabled={entitiesAllowCreateUsers.length === 0}
color="purple" />
className="w-full h-full col-span-2" {checkAccess(user, [
onClick={() => setModalOpen("gradingSystem")} "admin",
disabled={entitiesAllowEditGrading.length === 0} "corporate",
/> "developer",
)} "mastercorporate",
</div> ]) && (
)} <IconCard
</section> Icon={BsGearFill}
<section className="w-full"> label="Grading System"
<Lists user={user} entities={entities} permissions={permissions} /> color="purple"
</section> className="w-full h-full col-span-2"
</> onClick={() => setModalOpen("gradingSystem")}
</> disabled={entitiesAllowEditGrading.length === 0}
); />
)}
</div>
)}
</section>
<section className="w-full">
<Lists user={user} entities={entities} permissions={permissions} />
</section>
</>
</>
);
} }

View File

@@ -7,14 +7,14 @@ import { Session } from "@/hooks/useSessions";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { Assignment, AssignmentResult } from "@/interfaces/results"; import { Assignment, AssignmentResult } from "@/interfaces/results";
import { StudentUser, User } from "@/interfaces/user"; import { StudentUser, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getEntitiesAssignments } from "@/utils/assignments.be"; import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { getSessionsByAssignments } from "@/utils/sessions.be"; import { getSessionsByAssignments } from "@/utils/sessions.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
@@ -28,278 +28,399 @@ import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { import { BsBank, BsChevronLeft, BsX } from "react-icons/bs";
BsBank,
BsChevronLeft,
BsX,
} from "react-icons/bs";
interface Props { interface Props {
user: User; user: User;
students: StudentUser[]; students: StudentUser[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: Assignment[];
sessions: Session[] sessions: Session[];
exams: Exam[] exams: Exam[];
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, 'view_entity_statistics') isAdmin(user) ? undefined : entityIDS
);
const allowedEntities = findAllowedEntities(
user,
entities,
"view_entity_statistics"
);
if (allowedEntities.length === 0) return redirect("/") if (allowedEntities.length === 0) return redirect("/");
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students') const studentsAllowedEntities = findAllowedEntities(
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" }) user,
entities,
"view_students"
);
const assignments = await getEntitiesAssignments(mapBy(entities, "id")); const [students, assignments] = await Promise.all([
const sessions = await getSessionsByAssignments(mapBy(assignments, 'id')) getEntitiesUsers(mapBy(studentsAllowedEntities, "id"), { type: "student" }),
const exams = await getExamsByIds(assignments.flatMap(a => a.exams)) getEntitiesAssignments(mapBy(entities, "id")),
]);
return { props: serialize({ user, students, entities: allowedEntities, assignments, sessions, exams }) }; const [sessions, exams] = await Promise.all([
getSessionsByAssignments(mapBy(assignments, "id")),
getExamsByIds(assignments.flatMap((a) => a.exams)),
]);
return {
props: serialize({
user,
students,
entities: allowedEntities,
assignments,
sessions,
exams,
}),
};
}, sessionOptions); }, sessionOptions);
interface Item { interface Item {
student: StudentUser student: StudentUser;
result?: AssignmentResult result?: AssignmentResult;
assignment: Assignment assignment: Assignment;
exams: Exam[] exams: Exam[];
entity: Entity entity: Entity;
session?: Session session?: Session;
} }
const columnHelper = createColumnHelper<Item>(); const columnHelper = createColumnHelper<Item>();
export default function Statistical({ user, students, entities, assignments, sessions, exams }: Props) { export default function Statistical({
const [startDate, setStartDate] = useState<Date>(new Date()); user,
const [endDate, setEndDate] = useState<Date | null>(moment().add(1, 'month').toDate()); students,
const [selectedEntities, setSelectedEntities] = useState<string[]>([]) entities,
const [isDownloading, setIsDownloading] = useState(false) assignments,
sessions,
exams,
}: Props) {
const [startDate, setStartDate] = useState<Date>(new Date());
const [endDate, setEndDate] = useState<Date | null>(
moment().add(1, "month").toDate()
);
const [selectedEntities, setSelectedEntities] = useState<string[]>([]);
const [isDownloading, setIsDownloading] = useState(false);
const entitiesAllowDownload = useAllowedEntities(user, entities, 'download_statistics_report') const entitiesAllowDownload = useAllowedEntities(
user,
entities,
"download_statistics_report"
);
const resetDateRange = () => { const resetDateRange = () => {
const orderedAssignments = orderBy(assignments, ['startDate'], ['asc']) const orderedAssignments = orderBy(assignments, ["startDate"], ["asc"]);
setStartDate(moment(orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z").toDate()) setStartDate(
setEndDate(moment().add(1, 'month').toDate()) moment(
} orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z"
).toDate()
);
setEndDate(moment().add(1, "month").toDate());
};
useEffect(resetDateRange, [assignments]) useEffect(resetDateRange, [assignments]);
const updateDateRange = (dates: [Date, Date | null]) => { const updateDateRange = (dates: [Date, Date | null]) => {
const [start, end] = dates; const [start, end] = dates;
setStartDate(start!); setStartDate(start!);
setEndDate(end); setEndDate(end);
}; };
const toggleEntity = (id: string) => setSelectedEntities(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) const toggleEntity = (id: string) =>
setSelectedEntities((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
const renderAssignmentResolution = (entityID: string) => { const renderAssignmentResolution = (entityID: string) => {
const entityAssignments = filterBy(assignments, 'entity', entityID) const entityAssignments = filterBy(assignments, "entity", entityID);
const total = entityAssignments.reduce((acc, curr) => acc + curr.assignees.length, 0) const total = entityAssignments.reduce(
const results = entityAssignments.reduce((acc, curr) => acc + curr.results.length, 0) (acc, curr) => acc + curr.assignees.length,
0
);
const results = entityAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return `${results}/${total}` return `${results}/${total}`;
} };
const totalAssignmentResolution = useMemo(() => { const totalAssignmentResolution = useMemo(() => {
const total = assignments.reduce((acc, curr) => acc + curr.assignees.length, 0) const total = assignments.reduce(
const results = assignments.reduce((acc, curr) => acc + curr.results.length, 0) (acc, curr) => acc + curr.assignees.length,
0
);
const results = assignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return { results, total } return { results, total };
}, [assignments]) }, [assignments]);
const filteredAssignments = useMemo(() => { const filteredAssignments = useMemo(() => {
if (!startDate && !endDate) return assignments if (!startDate && !endDate) return assignments;
const startDateFiltered = startDate ? assignments.filter(a => moment(a.startDate).isSameOrAfter(moment(startDate))) : assignments const startDateFiltered = startDate
return endDate ? startDateFiltered.filter(a => moment(a.endDate).isSameOrBefore(moment(endDate))) : startDateFiltered ? assignments.filter((a) =>
}, [startDate, endDate, assignments]) moment(a.startDate).isSameOrAfter(moment(startDate))
)
: assignments;
return endDate
? startDateFiltered.filter((a) =>
moment(a.endDate).isSameOrBefore(moment(endDate))
)
: startDateFiltered;
}, [startDate, endDate, assignments]);
const data: Item[] = useMemo(() => const data: Item[] = useMemo(
filteredAssignments.filter(a => selectedEntities.includes(a.entity || "")).flatMap(a => a.assignees.map(x => { () =>
const result = findBy(a.results, 'user', x) filteredAssignments
const student = findBy(students, 'id', x) .filter((a) => selectedEntities.includes(a.entity || ""))
const entity = findBy(entities, 'id', a.entity) .flatMap((a) =>
const assignmentExams = exams.filter(e => a.exams.map(x => `${x.id}_${x.module}`).includes(`${e.id}_${e.module}`)) a.assignees.map((x) => {
const session = sessions.find(s => s.assignment?.id === a.id && s.user === x) const result = findBy(a.results, "user", x);
const student = findBy(students, "id", x);
const entity = findBy(entities, "id", a.entity);
const assignmentExams = exams.filter((e) =>
a.exams
.map((x) => `${x.id}_${x.module}`)
.includes(`${e.id}_${e.module}`)
);
const session = sessions.find(
(s) => s.assignment?.id === a.id && s.user === x
);
if (!student) return undefined if (!student) return undefined;
return { student, result, assignment: a, exams: assignmentExams, session, entity } return {
})).filter(x => !!x) as Item[], student,
[students, selectedEntities, filteredAssignments, exams, sessions, entities] result,
) assignment: a,
exams: assignmentExams,
session,
entity,
};
})
)
.filter((x) => !!x) as Item[],
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
);
const sortedData: Item[] = useMemo(() => data.sort((a, b) => { const sortedData: Item[] = useMemo(
const aTotalScore = a.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0 () =>
const bTotalScore = b.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0 data.sort((a, b) => {
const aTotalScore =
a.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
const bTotalScore =
b.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
return bTotalScore - aTotalScore return bTotalScore - aTotalScore;
}), [data]) }),
[data]
);
const downloadExcel = async () => { const downloadExcel = async () => {
setIsDownloading(true) setIsDownloading(true);
const request = await axios.post("/api/statistical", { const request = await axios.post(
entities: entities.filter(e => selectedEntities.includes(e.id)), "/api/statistical",
items: data, {
assignments: filteredAssignments, entities: entities.filter((e) => selectedEntities.includes(e.id)),
startDate, items: data,
endDate assignments: filteredAssignments,
}, { startDate,
responseType: 'blob' endDate,
}) },
{
responseType: "blob",
}
);
const href = URL.createObjectURL(request.data) const href = URL.createObjectURL(request.data);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = href; link.href = href;
link.setAttribute('download', `statistical_${new Date().toISOString()}.xlsx`); link.setAttribute(
document.body.appendChild(link); "download",
link.click(); `statistical_${new Date().toISOString()}.xlsx`
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(href); URL.revokeObjectURL(href);
setIsDownloading(false) setIsDownloading(false);
} };
const columns = [ const columns = [
columnHelper.accessor("student.name", { columnHelper.accessor("student.name", {
header: "Student", header: "Student",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("student.studentID", { columnHelper.accessor("student.studentID", {
header: "Student ID", header: "Student ID",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("student.email", { columnHelper.accessor("student.email", {
header: "E-mail", header: "E-mail",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("entity.label", { columnHelper.accessor("entity.label", {
header: "Entity", header: "Entity",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("assignment.name", { columnHelper.accessor("assignment.name", {
header: "Assignment", header: "Assignment",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("assignment.startDate", { columnHelper.accessor("assignment.startDate", {
header: "Date", header: "Date",
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY"), cell: (info) => moment(info.getValue()).format("DD/MM/YYYY"),
}), }),
columnHelper.accessor("result", { columnHelper.accessor("result", {
header: "Progress", header: "Progress",
cell: (info) => { cell: (info) => {
const student = info.row.original.student const student = info.row.original.student;
const session = info.row.original.session const session = info.row.original.session;
if (!student.lastLogin) return <span className="text-mti-red-dark">Never logged in</span> if (!student.lastLogin)
if (info.getValue()) return <span className="text-mti-green font-semibold">Submitted</span> return <span className="text-mti-red-dark">Never logged in</span>;
if (!session) return <span className="text-mti-rose">Not started</span> if (info.getValue())
return (
<span className="text-mti-green font-semibold">Submitted</span>
);
if (!session) return <span className="text-mti-rose">Not started</span>;
return <span className="font-semibold"> return (
{capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1} <span className="font-semibold">
</span> {capitalize(session.exam?.module || "")} Module, Part{" "}
}, {session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
}) </span>
] );
},
}),
];
return ( return (
<> <>
<Head> <Head>
<title>Statistical | EnCoach</title> <title>Statistical | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
<BsChevronLeft /> href="/dashboard"
</Link> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
<h2 className="font-bold text-2xl">Statistical</h2> >
</div> <BsChevronLeft />
<Checkbox </Link>
onChange={value => setSelectedEntities(value ? mapBy(entities, 'id') : [])} <h2 className="font-bold text-2xl">Statistical</h2>
isChecked={selectedEntities.length === entities.length} </div>
> <Checkbox
Select All onChange={(value) =>
</Checkbox> setSelectedEntities(value ? mapBy(entities, "id") : [])
</div> }
<Separator /> isChecked={selectedEntities.length === entities.length}
</div> >
Select All
</Checkbox>
</div>
<Separator />
</div>
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="w-full flex items-center justify-between gap-4 flex-wrap"> <div className="w-full flex items-center justify-between gap-4 flex-wrap">
{entities.map(entity => ( {entities.map((entity) => (
<button <button
onClick={() => toggleEntity(entity.id)} onClick={() => toggleEntity(entity.id)}
className={clsx( className={clsx(
"flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52", "flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52",
"transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple", "transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple",
selectedEntities.includes(entity.id) && "border-mti-purple text-mti-purple" selectedEntities.includes(entity.id) &&
)} "border-mti-purple text-mti-purple"
key={entity.id} )}
> key={entity.id}
<BsBank size={48} /> >
<div className="flex flex-col gap-1"> <BsBank size={48} />
<span>{entity.label}</span> <div className="flex flex-col gap-1">
<span className={clsx("font-semibold")}> <span>{entity.label}</span>
{renderAssignmentResolution(entity.id)} <span className={clsx("font-semibold")}>
</span> {renderAssignmentResolution(entity.id)}
</div> </span>
</button> </div>
))} </button>
</div> ))}
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ReactDatePicker <ReactDatePicker
className={clsx( className={clsx(
"p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selectsRange selectsRange
selected={startDate} selected={startDate}
onChange={updateDateRange} onChange={updateDateRange}
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
/> />
{startDate !== null && endDate !== null && ( {startDate !== null && endDate !== null && (
<button onClick={resetDateRange} className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10"> <button
<BsX size={24} /> onClick={resetDateRange}
</button> className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10"
)} >
</div> <BsX size={24} />
<span className="font-semibold text-lg pr-1"> </button>
Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total} )}
</span> </div>
</div> <span className="font-semibold text-lg pr-1">
</section> Total: {totalAssignmentResolution.results} /{" "}
{totalAssignmentResolution.total}
</span>
</div>
</section>
{selectedEntities.length > 0 && ( {selectedEntities.length > 0 && (
<Table <Table
columns={columns} columns={columns}
data={sortedData} data={sortedData}
searchFields={[["student", "name"], ["student", "email"], ["student", "studentID"], ["exams", "id"], ["assignment", "name"]]} searchFields={[
searchPlaceholder="Search by student, assignment or exam..." ["student", "name"],
onDownload={entitiesAllowDownload.length > 0 ? downloadExcel : undefined} ["student", "email"],
isDownloadLoading={isDownloading} ["student", "studentID"],
/> ["exams", "id"],
)} ["assignment", "name"],
</> ]}
</> searchPlaceholder="Search by student, assignment or exam..."
) onDownload={
entitiesAllowDownload.length > 0 ? downloadExcel : undefined
}
isDownloadLoading={isDownloading}
/>
)}
</>
</>
);
} }

View File

@@ -32,10 +32,7 @@ import { capitalize } from "lodash";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import { calculateBandScore } from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import { import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
MODULE_ARRAY,
sortByModule,
} from "@/utils/moduleUtils";
import { Chart } from "react-chartjs-2"; import { Chart } from "react-chartjs-2";
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
@@ -45,7 +42,6 @@ import { Stat, User } from "@/interfaces/user";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import Badge from "@/components/Low/Badge"; import Badge from "@/components/Low/Badge";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntities } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
@@ -69,19 +65,10 @@ const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return redirect("/login"); if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/"); if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, "id");
const isAdmin = checkAccess(user, ["admin", "developer"]); const isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntities(isAdmin ? undefined : entityIDs, {
id: 1,
label: 1,
});
return { return {
props: serialize({ user, entities, isAdmin }), props: serialize({ user, isAdmin }),
}; };
}, sessionOptions); }, sessionOptions);

View File

@@ -1,225 +1,269 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {ToastContainer} from "react-toastify"; import { ToastContainer } from "react-toastify";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import {FaPlus} from "react-icons/fa"; import { FaPlus } from "react-icons/fa";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import router from "next/router"; import router from "next/router";
import useTrainingContentStore from "@/stores/trainingContentStore"; import useTrainingContentStore from "@/stores/trainingContentStore";
import axios from "axios"; import axios from "axios";
import {ITrainingContent} from "@/training/TrainingInterfaces"; import { ITrainingContent } from "@/training/TrainingInterfaces";
import moment from "moment"; import moment from "moment";
import {uuidv4} from "@firebase/util"; import { uuidv4 } from "@firebase/util";
import TrainingScore from "@/training/TrainingScore"; import TrainingScore from "@/training/TrainingScore";
import ModuleBadge from "@/components/ModuleBadge"; import ModuleBadge from "@/components/ModuleBadge";
import RecordFilter from "@/components/Medium/RecordFilter"; import RecordFilter from "@/components/Medium/RecordFilter";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { checkAccess } from "../../utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const isAdmin = checkAccess(user, ["admin", "developer"]);
const entityIDs = mapBy(user.entities, "id");
const users = await getEntitiesUsers(entityIDs);
const entityIDs = mapBy(user.entities, 'id') return {
const entities = await getEntitiesWithRoles(entityIDs) props: serialize({ user, users, isAdmin }),
const users = await getEntitiesUsers(entityIDs) };
return {
props: serialize({user, users, entities}),
};
}, sessionOptions); }, sessionOptions);
const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => { const Training: React.FC<{
const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]); user: User;
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); entities: EntityWithRoles[];
users: User[];
isAdmin: boolean;
}> = ({ user, entities, isAdmin }) => {
const [recordUserId, setRecordTraining] = useRecordStore((state) => [
state.selectedUser,
state.setTraining,
]);
const [filter, setFilter] = useState<
"months" | "weeks" | "days" | "assignments"
>();
const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]); const [stats, setTrainingStats] = useTrainingContentStore((state) => [
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0); state.stats,
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>(); state.setStats,
]);
const [isNewContentLoading, setIsNewContentLoading] = useState(
stats.length != 0
);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{
[key: string]: ITrainingContent;
}>();
const {data: trainingContent, isLoading: areRecordsLoading} = useFilterRecordsByUser<ITrainingContent[]>( const { data: trainingContent, isLoading: areRecordsLoading } =
recordUserId || user?.id, useFilterRecordsByUser<ITrainingContent[]>(
undefined, recordUserId || user?.id,
"training", undefined,
); "training"
);
useEffect(() => { useEffect(() => {
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
setTrainingStats([]); setTrainingStats([]);
}; };
router.events.on("routeChangeStart", handleRouteChange); router.events.on("routeChangeStart", handleRouteChange);
return () => { return () => {
router.events.off("routeChangeStart", handleRouteChange); router.events.off("routeChangeStart", handleRouteChange);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events, setTrainingStats]); }, [router.events, setTrainingStats]);
useEffect(() => { useEffect(() => {
const postStats = async () => { const postStats = async () => {
try { try {
const response = await axios.post<{id: string}>(`/api/training`, {userID: user.id, stats: stats}); const response = await axios.post<{ id: string }>(`/api/training`, {
return response.data.id; userID: user.id,
} catch (error) { stats: stats,
setIsNewContentLoading(false); });
} return response.data.id;
}; } catch (error) {
setIsNewContentLoading(false);
}
};
if (isNewContentLoading) { if (isNewContentLoading) {
postStats().then((id) => { postStats().then((id) => {
setTrainingStats([]); setTrainingStats([]);
if (id) { if (id) {
router.push(`/training/${id}`); router.push(`/training/${id}`);
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewContentLoading]); }, [isNewContentLoading]);
const handleNewTrainingContent = () => { const handleNewTrainingContent = () => {
setRecordTraining(true); setRecordTraining(true);
router.push("/record"); router.push("/record");
}; };
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => { const filterTrainingContentByDate = (trainingContent: {
if (filter) { [key: string]: ITrainingContent;
const filterDate = moment() }) => {
.subtract({[filter as string]: 1}) if (filter) {
.format("x"); const filterDate = moment()
const filteredTrainingContent: {[key: string]: ITrainingContent} = {}; .subtract({ [filter as string]: 1 })
.format("x");
const filteredTrainingContent: { [key: string]: ITrainingContent } = {};
Object.keys(trainingContent).forEach((timestamp) => { Object.keys(trainingContent).forEach((timestamp) => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp]; if (timestamp >= filterDate)
}); filteredTrainingContent[timestamp] = trainingContent[timestamp];
return filteredTrainingContent; });
} return filteredTrainingContent;
return trainingContent; }
}; return trainingContent;
};
useEffect(() => { useEffect(() => {
if (trainingContent.length > 0) { if (trainingContent.length > 0) {
const grouped = trainingContent.reduce((acc, content) => { const grouped = trainingContent.reduce((acc, content) => {
acc[content.created_at] = content; acc[content.created_at] = content;
return acc; return acc;
}, {} as {[key: number]: ITrainingContent}); }, {} as { [key: number]: ITrainingContent });
setGroupedByTrainingContent(grouped); setGroupedByTrainingContent(grouped);
} else { } else {
setGroupedByTrainingContent(undefined); setGroupedByTrainingContent(undefined);
} }
}, [trainingContent]); }, [trainingContent]);
const formatTimestamp = (timestamp: string) => { const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp)); const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm"; const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter); return date.format(formatter);
}; };
const selectTrainingContent = (trainingContent: ITrainingContent) => { const selectTrainingContent = (trainingContent: ITrainingContent) => {
router.push(`/training/${trainingContent.id}`); router.push(`/training/${trainingContent.id}`);
}; };
const trainingContentContainer = (timestamp: string) => { const trainingContentContainer = (timestamp: string) => {
if (!groupedByTrainingContent) return <></>; if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp]; const trainingContent: ITrainingContent =
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))]; groupedByTrainingContent[timestamp];
const uniqueModules = [
...new Set(trainingContent.exams.map((exam) => exam.module)),
];
return ( return (
<> <>
<div <div
key={uuidv4()} key={uuidv4()}
className={clsx( 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", "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)} onClick={() => selectTrainingContent(trainingContent)}
role="button"> 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"> <div className="w-full flex justify-between -md:items-center 2xl:items-center">
<span className="font-medium">{formatTimestamp(timestamp)}</span> <div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
</div> <span className="font-medium">{formatTimestamp(timestamp)}</span>
<div className="flex flex-col gap-2"> </div>
<div className="w-full flex flex-row gap-1"> <div className="flex flex-col gap-2">
{uniqueModules.map((module) => ( <div className="w-full flex flex-row gap-1">
<ModuleBadge key={module} module={module} /> {uniqueModules.map((module) => (
))} <ModuleBadge key={module} module={module} />
</div> ))}
</div> </div>
</div> </div>
<TrainingScore trainingContent={trainingContent} gridView={true} /> </div>
</div> <TrainingScore trainingContent={trainingContent} gridView={true} />
</> </div>
); </>
}; );
};
return ( return (
<> <>
<Head> <Head>
<title>Training | EnCoach</title> <title>Training | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
{isNewContentLoading || areRecordsLoading ? ( {isNewContentLoading || areRecordsLoading ? (
<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"> <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" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && ( {isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">Assessing your exams, please be patient...</span> <span className="text-center text-2xl font-bold text-mti-green-light">
)} Assessing your exams, please be patient...
</div> </span>
) : ( )}
<> </div>
<RecordFilter entities={entities} user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}> ) : (
{user.type === "student" && ( <>
<> <RecordFilter
<div className="flex items-center"> entities={entities}
<div className="font-semibold text-2xl">Generate New Training Material</div> user={user}
<button isAdmin={isAdmin}
className={clsx( filterState={{ filter: filter, setFilter: setFilter }}
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4", assignments={false}
"transition duration-300 ease-in-out", >
)} {user.type === "student" && (
onClick={handleNewTrainingContent}> <>
<FaPlus /> <div className="flex items-center">
</button> <div className="font-semibold text-2xl">
</div> Generate New Training Material
</> </div>
)} <button
</RecordFilter> className={clsx(
{trainingContent.length == 0 && ( "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
<div className="flex flex-grow justify-center items-center"> "transition duration-300 ease-in-out"
<span className="font-semibold ml-1">No training content to display...</span> )}
</div> onClick={handleNewTrainingContent}
)} >
{!areRecordsLoading && groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && ( <FaPlus />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6"> </button>
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent)) </div>
.sort((a, b) => parseInt(b) - parseInt(a)) </>
.map(trainingContentContainer)} )}
</div> </RecordFilter>
)} {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>
)}
{!areRecordsLoading &&
groupedByTrainingContent &&
Object.keys(groupedByTrainingContent).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
{Object.keys(
filterTrainingContentByDate(groupedByTrainingContent)
)
.sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)}
</div>
)}
</>
)}
</>
</>
);
}; };
export default Training; export default Training;

View File

@@ -1,15 +1,15 @@
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { Group, Stat, StudentUser, User } from "@/interfaces/user"; import { Group, Stat, StudentUser, User } from "@/interfaces/user";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { BsChevronLeft } from "react-icons/bs"; import { BsChevronLeft } from "react-icons/bs";
import { mapBy, serialize } from "@/utils"; import { mapBy, serialize } from "@/utils";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { Entity } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { getParticipantsGroups } from "@/utils/groups.be"; import { getParticipantsGroups } from "@/utils/groups.be";
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList"; import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
import Head from "next/head"; import Head from "next/head";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
@@ -17,73 +17,84 @@ import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs) const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, "view_student_performance") checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs
);
const allowedEntities = findAllowedEntities(
user,
entities,
"view_student_performance"
);
if (allowedEntities.length === 0) return redirect("/") if (allowedEntities.length === 0) return redirect("/");
const students = await (checkAccess(user, ["admin", 'developer']) const students = await (checkAccess(user, ["admin", "developer"])
? getUsers({ type: 'student' }) ? getUsers({ type: "student" })
: getEntitiesUsers(mapBy(allowedEntities, 'id'), { type: 'student' }) : getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" }));
) const groups = await getParticipantsGroups(mapBy(students, "id"));
const groups = await getParticipantsGroups(mapBy(students, 'id'))
return { return {
props: serialize({ user, students, entities, groups }), props: serialize({ user, students, entities, groups }),
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
students: StudentUser[] students: StudentUser[];
entities: Entity[] entities: Entity[];
groups: Group[] groups: Group[];
} }
const StudentPerformance = ({ user, students, entities, groups }: Props) => { const StudentPerformance = ({ user, students, entities, groups }: Props) => {
const { data: stats } = useFilterRecordsByUser<Stat[]>(); const { data: stats } = useFilterRecordsByUser<Stat[]>();
const router = useRouter(); const router = useRouter();
const performanceStudents = students.map((u) => ({ const performanceStudents = students.map((u) => ({
...u, ...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A", group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
entitiesLabel: mapBy(u.entities, 'id').map((id) => entities.find((e) => e.id === id)?.label).filter((e) => !!e).join(', '), entitiesLabel: mapBy(u.entities, "id")
})); .map((id) => entities.find((e) => e.id === id)?.label)
.filter((e) => !!e)
.join(", "),
}));
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
router.back() router.back();
}} }}
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
<BsChevronLeft /> >
</button> <BsChevronLeft />
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2> </button>
</div> <h2 className="font-bold text-2xl">
<StudentPerformanceList items={performanceStudents} stats={stats} /> Student Performance ({students.length})
</> </h2>
</> </div>
); <StudentPerformanceList items={performanceStudents} stats={stats} />
</>
</>
);
}; };
export default StudentPerformance; export default StudentPerformance;

View File

@@ -19,8 +19,8 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end
return await db.collection("assignments").find<Assignment>(query).toArray(); return await db.collection("assignments").find<Assignment>(query).toArray();
}; };
export const getAssignments = async () => { export const getAssignments = async (projection = {}) => {
return await db.collection("assignments").find<Assignment>({}).toArray(); return await db.collection("assignments").find<Assignment>({}, { projection }).toArray();
}; };
export const getAssignment = async (id: string) => { export const getAssignment = async (id: string) => {

View File

@@ -9,7 +9,6 @@ const db = client.db(process.env.MONGODB_DB);
export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles | undefined> => { export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles | undefined> => {
const entity = await getEntity(id); const entity = await getEntity(id);
if (!entity) return undefined; if (!entity) return undefined;
const roles = await getRolesByEntity(id); const roles = await getRolesByEntity(id);
return { ...entity, roles }; return { ...entity, roles };
}; };

View File

@@ -1,11 +1,8 @@
import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore";
import { groupBy, shuffle } from "lodash"; import { groupBy, shuffle } from "lodash";
import { CEFRLevels, Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam"; import { CEFRLevels, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam";
import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user"; import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getCorporateUser } from "@/resources/user"; import { Db } from "mongodb";
import { getUserCorporate } from "./groups.be";
import { Db, ObjectId } from "mongodb";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { MODULE_ARRAY } from "./moduleUtils"; import { MODULE_ARRAY } from "./moduleUtils";
import { mapBy } from "."; import { mapBy } from ".";

View File

@@ -3,10 +3,10 @@ import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export const getSessionsByUser = async (id: string, limit = 0, filter = {}) => export const getSessionsByUser = async (id: string, limit = 0, filter = {}, projection = {}) =>
await db await db
.collection("sessions") .collection("sessions")
.find<Session>({ user: id, ...filter }) .find<Session>({ user: id, ...filter }, { projection })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();

View File

@@ -129,19 +129,19 @@ export async function getUser(id: string, projection = {}): Promise<User | undef
return !!user ? user : undefined; return !!user ? user : undefined;
} }
export async function getSpecificUsers(ids: string[]) { export async function getSpecificUsers(ids: string[], projection = {}) {
if (ids.length === 0) return []; if (ids.length === 0) return [];
return await db return await db
.collection("users") .collection("users")
.find<User>({ id: { $in: ids } }, { projection: { _id: 0 } }) .find<User>({ id: { $in: ids } }, { projection: { _id: 0, ...projection } })
.toArray(); .toArray();
} }
export async function getEntityUsers(id: string, limit?: number, filter?: object) { export async function getEntityUsers(id: string, limit?: number, filter?: object, projection = {}) {
return await db return await db
.collection("users") .collection("users")
.find<User>({ "entities.id": id, ...(filter || {}) }) .find<User>({ "entities.id": id, ...(filter || {}) }, { projection: { _id: 0, ...projection } })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
} }
@@ -231,7 +231,7 @@ export async function getUserBalance(user: User) {
); );
} }
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => { export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[], projection = {}) => {
const { const {
["view_students"]: allowedStudentEntities, ["view_students"]: allowedStudentEntities,
@@ -244,12 +244,12 @@ export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]
'view_corporates', 'view_corporates',
'view_mastercorporates', 'view_mastercorporates',
]); ]);
const [students, teachers, corporates, masterCorporates] = await Promise.all([
getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }, 0, projection),
const students = await getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }) getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }, 0, projection),
const teachers = await getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }) getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }, 0, projection),
const corporates = await getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }) getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }, 0, projection),
const masterCorporates = await getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }) ])
return [...students, ...teachers, ...corporates, ...masterCorporates] return [...students, ...teachers, ...corporates, ...masterCorporates]
} }
@@ -266,11 +266,12 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
'view_corporates', 'view_corporates',
'view_mastercorporates', 'view_mastercorporates',
]); ]);
const [student, teacher, corporate, mastercorporate] = await Promise.all([
const student = await countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }) countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
const teacher = await countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }) countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
const corporate = await countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }) countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
const mastercorporate = await countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }) countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
])
return { student, teacher, corporate, mastercorporate } return { student, teacher, corporate, mastercorporate }
} }