Continued creating the entity system

This commit is contained in:
Tiago Ribeiro
2024-10-01 17:39:43 +01:00
parent bae02e5192
commit 564e6438cb
37 changed files with 2522 additions and 130 deletions

View File

@@ -0,0 +1,32 @@
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {ReactNode} from "react";
import Checkbox from "../Low/Checkbox";
import Separator from "../Low/Separator";
interface Props<T> {
list: T[];
searchFields: string[][];
pageSize?: number;
firstCard?: () => ReactNode;
renderCard: (item: T) => ReactNode;
}
export default function CardList<T>({list, searchFields, renderCard, firstCard, pageSize = 20}: Props<T>) {
const {rows, renderSearch} = useListSearch(searchFields, list);
const {items, page, renderMinimal} = usePagination(rows, pageSize);
return (
<section className="flex flex-col gap-4 w-full">
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
<div className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{page === 0 && !!firstCard && firstCard()}
{items.map(renderCard)}
</div>
</section>
);
}

View File

@@ -0,0 +1,67 @@
import {Invite, InviteWithUsers} from "@/interfaces/invite";
import {User} from "@/interfaces/user";
import {getUserName} from "@/utils/users";
import axios from "axios";
import {useMemo, useState} from "react";
import {BsArrowRepeat} from "react-icons/bs";
import {toast} from "react-toastify";
interface Props {
invite: InviteWithUsers;
reload: () => void;
}
export default function InviteWithUserCard({invite, reload}: Props) {
const [isLoading, setIsLoading] = useState(false);
const name = useMemo(() => (!invite.from ? null : getUserName(invite.from)), [invite.from]);
const decide = (decision: "accept" | "decline") => {
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
setIsLoading(true);
axios
.get(`/api/invites/${decision}/${invite.id}`)
.then(() => {
toast.success(`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`, {toastId: "success"});
reload();
})
.catch((e) => {
toast.success(`Something went wrong, please try again later!`, {
toastId: "error",
});
reload();
})
.finally(() => setIsLoading(false));
};
return (
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
<span>Invited by {name}</span>
<div className="flex items-center gap-2">
<button
onClick={() => decide("accept")}
disabled={isLoading}
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Accept"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={() => decide("decline")}
disabled={isLoading}
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Decline"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
}

View File

@@ -1,61 +1,40 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
import useUsers from "@/hooks/useUsers";
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import {dateSorter, mapBy} from "@/utils";
import moment from "moment";
import {useEffect, useMemo, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
BsDatabase,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "../IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
import {getUserCorporate} from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "../AssignmentView";
import AssignmentCreator from "../AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "../AssignmentCard";
import {createColumnHelper} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage";
import StudentPerformancePage from "./StudentPerformancePage";
import MasterStatistical from "../MasterCorporate/MasterStatistical";
import MasterStatisticalPage from "./MasterStatisticalPage";
import {getEntitiesUsers} from "@/utils/users.be";
interface Props {
user: CorporateUser;
@@ -91,19 +70,6 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const assignmentsUsers = useMemo(
() =>
[...teachers, ...students].filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id),
),
[groups, teachers, students, selectedUser],
);
useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, router.asPath]);

View File

@@ -22,10 +22,10 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c
};
return (
<div
<button
onClick={onClick}
className={clsx(
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
"bg-white border rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-full h-52 justify-center cursor-pointer hover:shadow-lg hover:border-mti-purple-dark transition ease-in-out duration-300",
tooltip && "tooltip tooltip-bottom",
isSelected && `border border-solid border-${colorClasses[color]}`,
className,
@@ -38,6 +38,6 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c
{isLoading ? "..." : value}
</span>
</span>
</div>
</button>
);
}

View File

@@ -3,14 +3,17 @@ export interface Entity {
label: string;
}
export interface Roles {
export interface Role {
id: string;
entityID: string;
permissions: string[];
label: string;
}
export interface EntityWithPermissions extends Entity {
roles: Roles[];
export interface EntityWithRoles extends Entity {
roles: Role[];
}
export type WithEntity<T> = T extends {entities: string[]} ? T & {entities: Entity[]} : T;
export type WithEntity<T> = T extends {entities: {id: string; role: string}[]}
? Omit<T, "entities"> & {entities: {entity?: Entity; role?: Role}[]}
: T;

View File

@@ -8,5 +8,6 @@ export interface Step {
export interface Grading {
user: string;
entity?: string;
steps: Step[];
}

View File

@@ -1,5 +1,11 @@
import {User} from "./user";
export interface Invite {
id: string;
from: string;
to: string;
id: string;
from: string;
to: string;
}
export interface InviteWithUsers extends Omit<Invite, "from"> {
from?: User;
}

View File

@@ -34,6 +34,7 @@ export interface Assignment {
start?: boolean;
autoStartDate?: Date;
autoStart?: boolean;
entity?: string;
}
export type AssignmentWithCorporateId = Assignment & {corporateId: string};

View File

@@ -22,7 +22,7 @@ export interface BasicUser {
status: UserStatus;
permissions: PermissionType[];
lastLogin?: Date;
entities: string[];
entities: {id: string; role: string}[];
}
export interface StudentUser extends BasicUser {
@@ -150,6 +150,7 @@ export interface Group {
participants: string[];
id: string;
disableEditing?: boolean;
entity?: string;
}
export interface GroupWithUsers extends Omit<Group, "participants" | "admin"> {

View File

@@ -0,0 +1,46 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {getEntity, getEntityWithRoles} from "@/utils/entities.be";
import client from "@/lib/mongodb";
import {Entity} from "@/interfaces/entity";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "get") return await get(req, res);
if (req.method === "PATCH") return await patch(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id, showRoles} = req.query as {id: string; showRoles: string};
const entity = await (!!showRoles ? getEntityWithRoles : getEntity)(id);
res.status(200).json(entity);
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const user = req.session.user;
if (!user.entities.map((x) => x.id).includes(id)) {
return res.status(403).json({ok: false});
}
const entity = await db.collection<Entity>("entities").updateOne({id}, {$set: {label: req.body.label}});
return res.status(200).json({ok: entity.acknowledged});
}

View File

@@ -0,0 +1,65 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {countEntityUsers, getEntityUsers} from "@/utils/users.be";
import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "get") return await get(req, res);
if (req.method === "PATCH") return await patch(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id, onlyCount} = req.query as {id: string; onlyCount: string};
if (onlyCount) return res.status(200).json(await countEntityUsers(id));
const users = await getEntityUsers(id);
res.status(200).json(users);
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const {add, members, role} = req.body as {add: boolean; members: string[]; role?: string};
if (add) {
await db.collection("users").updateMany(
{id: {$in: members}},
{
// @ts-expect-error
$push: {
entities: {id, role},
},
},
);
return res.status(204).end();
}
await db.collection("users").updateMany(
{id: {$in: members}},
{
// @ts-expect-error
$pull: {
entities: {id},
},
},
);
return res.status(204).end();
}

View File

@@ -0,0 +1,48 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
import {Entity} from "@/interfaces/entity";
import {v4} from "uuid";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
if (req.method === "POST") return await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const user = req.session.user;
const {showRoles} = req.query as {showRoles: string};
const getFn = showRoles ? getEntitiesWithRoles : getEntities;
if (["admin", "developer"].includes(user.type)) return res.status(200).json(await getFn());
res.status(200).json(await getFn(user.entities.map((x) => x.id)));
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const user = req.session.user;
if (!["admin", "developer"].includes(user.type)) {
return res.status(403).json({ok: false});
}
const entity: Entity = {
id: v4(),
label: req.body.label,
};
return res.status(200).json(entity);
}

View File

@@ -1,13 +1,15 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
import client from "@/lib/mongodb";
import type {NextApiRequest, NextApiResponse} from "next";
const db = client.db(process.env.MONGODB_DB);
type Data = {
name: string
}
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({ name: 'John Doe' })
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
await db.collection("users").updateMany({}, {$set: {entities: []}});
res.status(200).json({name: "John Doe"});
}

View File

@@ -0,0 +1,192 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import IconCard from "@/dashboards/IconCard";
import {Module} from "@/interfaces";
import {EntityWithRoles} from "@/interfaces/entity";
import {Assignment} from "@/interfaces/results";
import {Group, Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {dateSorter, filterBy, mapBy, serialize} from "@/utils";
import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {getGroups, getGroupsByEntities} from "@/utils/groups.be";
import {checkAccess} from "@/utils/permissions";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import {getStatsByUsers} from "@/utils/stats.be";
import {getEntitiesUsers, getUsers} from "@/utils/users.be";
import {withIronSessionSsr} from "iron-session/next";
import {uniqBy} from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useMemo} from "react";
import {
BsBank,
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
const users = await getUsers();
const entities = await getEntitiesWithRoles();
const assignments = await getAssignments();
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroups();
return {props: serialize({user, users, entities, assignments, stats, groups})};
}, sessionOptions);
export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard Icon={BsPersonFill} label="Students" value={students.length} color="purple" />
<IconCard Icon={BsPencilSquare} label="Teachers" value={teachers.length} color="purple" />
<IconCard Icon={BsBank} label="Corporates" value={corporates.length} color="purple" />
<IconCard Icon={BsBank} label="Master Corporates" value={masterCorporates.length} color="purple" />
<IconCard Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" />
<IconCard Icon={BsPeopleFill} label="Entities" value={entities.length} color="purple" />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear} label="Student Performance" value={students.length} color="purple" />
<IconCard Icon={BsEnvelopePaper} label="Assignments" value={assignments.filter((a) => !a.archived).length} color="purple" />
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,208 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import IconCard from "@/dashboards/IconCard";
import {Module} from "@/interfaces";
import {EntityWithRoles} from "@/interfaces/entity";
import {Assignment} from "@/interfaces/results";
import {Group, Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {dateSorter, filterBy, mapBy, serialize} from "@/utils";
import {getEntitiesAssignments} from "@/utils/assignments.be";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {getGroupsByEntities} from "@/utils/groups.be";
import {checkAccess} from "@/utils/permissions";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import {getStatsByUsers} from "@/utils/stats.be";
import {getEntitiesUsers} from "@/utils/users.be";
import {withIronSessionSsr} from "iron-session/next";
import {uniqBy} from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useMemo} from "react";
import {
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer", "corporate"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
const entityIDS = mapBy(user.entities, "id") || [];
const users = await getEntitiesUsers(entityIDS);
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return {props: serialize({user, users, entities, assignments, stats, groups})};
}, sessionOptions);
export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard Icon={BsPersonFill} label="Students" value={students.length} color="purple" />
<IconCard Icon={BsPencilSquare} label="Teachers" value={teachers.length} color="purple" />
<IconCard Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" />
<IconCard Icon={BsPeopleFill} label="Entities" value={entities.length} color="purple" />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear} label="Student Performance" value={students.length} color="purple" />
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsEnvelopePaper}
className="col-span-2"
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
</div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,192 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import IconCard from "@/dashboards/IconCard";
import {Module} from "@/interfaces";
import {EntityWithRoles} from "@/interfaces/entity";
import {Assignment} from "@/interfaces/results";
import {Group, Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {dateSorter, filterBy, mapBy, serialize} from "@/utils";
import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {getGroups, getGroupsByEntities} from "@/utils/groups.be";
import {checkAccess} from "@/utils/permissions";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import {getStatsByUsers} from "@/utils/stats.be";
import {getEntitiesUsers, getUsers} from "@/utils/users.be";
import {withIronSessionSsr} from "iron-session/next";
import {uniqBy} from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useMemo} from "react";
import {
BsBank,
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
const users = await getUsers();
const entities = await getEntitiesWithRoles();
const assignments = await getAssignments();
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroups();
return {props: serialize({user, users, entities, assignments, stats, groups})};
}, sessionOptions);
export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard Icon={BsPersonFill} label="Students" value={students.length} color="purple" />
<IconCard Icon={BsPencilSquare} label="Teachers" value={teachers.length} color="purple" />
<IconCard Icon={BsBank} label="Corporates" value={corporates.length} color="purple" />
<IconCard Icon={BsBank} label="Master Corporates" value={masterCorporates.length} color="purple" />
<IconCard Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" />
<IconCard Icon={BsPeopleFill} label="Entities" value={entities.length} color="purple" />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear} label="Student Performance" value={students.length} color="purple" />
<IconCard Icon={BsEnvelopePaper} label="Assignments" value={assignments.filter((a) => !a.archived).length} color="purple" />
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,27 @@
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {withIronSessionSsr} from "iron-session/next";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
return {
redirect: {
destination: `/dashboard/${user.type}`,
permanent: false,
},
};
}, sessionOptions);
export default function Dashboard() {
return <div></div>;
}

View File

@@ -0,0 +1,198 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import IconCard from "@/dashboards/IconCard";
import {Module} from "@/interfaces";
import {EntityWithRoles} from "@/interfaces/entity";
import {Assignment} from "@/interfaces/results";
import {Group, Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {dateSorter, filterBy, mapBy, serialize} from "@/utils";
import {getEntitiesAssignments} from "@/utils/assignments.be";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {getGroupsByEntities} from "@/utils/groups.be";
import {checkAccess} from "@/utils/permissions";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import {getStatsByUsers} from "@/utils/stats.be";
import {getEntitiesUsers} from "@/utils/users.be";
import {withIronSessionSsr} from "iron-session/next";
import {uniqBy} from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useMemo} from "react";
import {
BsBank,
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
const entityIDS = mapBy(user.entities, "id") || [];
const users = await getEntitiesUsers(entityIDS);
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return {props: serialize({user, users, entities, assignments, stats, groups})};
}, sessionOptions);
export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard Icon={BsPersonFill} label="Students" value={students.length} color="purple" />
<IconCard Icon={BsPencilSquare} label="Teachers" value={teachers.length} color="purple" />
<IconCard Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" />
<IconCard Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" />
<IconCard Icon={BsPeopleFill} label="Entities" value={entities.length} color="purple" />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear} label="Student Performance" value={students.length} color="purple" />
<IconCard Icon={BsEnvelopePaper} label="Assignments" value={assignments.filter((a) => !a.archived).length} color="purple" />
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,298 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
import ModuleBadge from "@/components/ModuleBadge";
import ProfileSummary from "@/components/ProfileSummary";
import {Session} from "@/hooks/useSessions";
import {Grading} from "@/interfaces";
import {EntityWithRoles} from "@/interfaces/entity";
import {Exam} from "@/interfaces/exam";
import {InviteWithUsers} from "@/interfaces/invite";
import {Assignment} from "@/interfaces/results";
import {Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import useExamStore from "@/stores/examStore";
import {mapBy, serialize} from "@/utils";
import {activeAssignmentFilter} from "@/utils/assignments";
import {getAssignmentsByAssignee} from "@/utils/assignments.be";
import {getEntityWithRoles} from "@/utils/entities.be";
import {getExamsByIds} from "@/utils/exams.be";
import {getGradingSystemByEntity} from "@/utils/grading.be";
import {convertInvitersToUsers, getInvitesByInvitee} from "@/utils/invites.be";
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {checkAccess} from "@/utils/permissions";
import {getGradingLabel} from "@/utils/score";
import {getSessionsByUser} from "@/utils/sessions.be";
import {averageScore} from "@/utils/stats";
import {getStatsByUser} from "@/utils/stats.be";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import {capitalize, uniqBy} from "lodash";
import moment from "moment";
import Head from "next/head";
import {useRouter} from "next/router";
import {BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
interface Props {
user: User;
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
exams: Exam[];
sessions: Session[];
invites: InviteWithUsers[];
grading: Grading;
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
const entityIDS = mapBy(user.entities, "id") || [];
const entityID = entityIDS.length > 0 ? entityIDS[0] : "";
const entities = await getEntityWithRoles(entityID);
const allAssignments = await getAssignmentsByAssignee(user.id, {archived: false});
const stats = await getStatsByUser(user.id);
const sessions = await getSessionsByUser(user.id, 10);
const invites = await getInvitesByInvitee(user.id);
const grading = await getGradingSystemByEntity(entityID);
const formattedInvites = await Promise.all(invites.map(convertInvitersToUsers));
const assignments = allAssignments.filter(activeAssignmentFilter);
const examIDs = uniqBy(
assignments.flatMap((a) =>
a.exams.filter((e) => e.assignee === user.id).map((e) => ({module: e.module, id: e.id, key: `${e.module}_${e.id}`})),
),
"key",
);
const exams = await getExamsByIds(examIDs);
return {props: serialize({user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading})};
}, sessionOptions);
export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: Props) {
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setAssignment = useExamStore((state) => state.setAssignment);
const startAssignment = (assignment: Assignment) => {
if (exams.every((x) => !!x)) {
setUserSolutions([]);
setShowSolutions(false);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
setAssignment(assignment);
router.push("/exercises");
}
};
const studentAssignments = assignments.filter(activeAssignmentFilter);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
{entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<ProfileSummary
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: countFullExams(stats),
label: "Exams",
tooltip: "Number of all conducted completed exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: countExamModules(stats),
label: "Modules",
tooltip: "Number of all exam modules performed including Level Test",
},
{
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
tooltip: "Average success rate for questions responded",
},
]}
/>
{/* Bio */}
<section className="flex flex-col gap-1 md:gap-3">
<span className="text-lg font-bold">Bio</span>
<span className="text-mti-gray-taupe">
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
</span>
</section>
{/* Assignments */}
<section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
{studentAssignments
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => (
<div
className={clsx(
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
)}
key={assignment.id}>
<div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
<span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
</div>
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment">
<Button className="h-full w-full !rounded-xl" variant="outline">
Start
</Button>
</div>
<div
data-tip="You have already started this assignment!"
className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
)}>
<Button
className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)}
variant="outline"
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
Start
</Button>
</div>
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
<Button
onClick={() => router.push("/record")}
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline">
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
{/* Invites */}
{invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => (
<InviteWithUserCard key={invite.id} invite={invite} reload={() => router.replace(router.asPath)} />
))}
</span>
</section>
)}
{/* Score History */}
<section className="flex flex-col gap-3">
<span className="text-lg font-bold">Score History</span>
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0;
return (
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
</div>
<div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
<span className="text-mti-gray-dim text-sm font-normal">
{module === "level" && !!grading && `English Level: ${getGradingLabel(level, grading.steps)}`}
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
</span>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={module === "level" ? level : Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,182 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import IconCard from "@/dashboards/IconCard";
import {Module} from "@/interfaces";
import {EntityWithRoles} from "@/interfaces/entity";
import {Assignment} from "@/interfaces/results";
import {Group, Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {dateSorter, filterBy, mapBy, serialize} from "@/utils";
import {getEntitiesAssignments} from "@/utils/assignments.be";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {getGroupsByEntities} from "@/utils/groups.be";
import {checkAccess} from "@/utils/permissions";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import {getStatsByUsers} from "@/utils/stats.be";
import {getEntitiesUsers} from "@/utils/users.be";
import {withIronSessionSsr} from "iron-session/next";
import {uniqBy} from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useMemo} from "react";
import {
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer", "teacher"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
const entityIDS = mapBy(user.entities, "id") || [];
const users = await getEntitiesUsers(entityIDS);
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return {props: serialize({user, users, entities, assignments, stats, groups})};
}, sessionOptions);
export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard Icon={BsPersonFill} label="Students" value={students.length} color="purple" />
<IconCard Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsEnvelopePaper} label="Assignments" value={assignments.filter((a) => !a.archived).length} color="purple" />
</section>
</div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,343 @@
/* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
import {GroupWithUsers, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import {getEntityWithRoles} from "@/utils/entities.be";
import {convertToUsers, getGroup} from "@/utils/groups.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {getUserName} from "@/utils/users";
import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import {
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus,
BsSquare,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({req, params}) => {
const user = req.session.user as User;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
const {id} = params as {id: string};
const entityWithRoles = await getEntityWithRoles(id);
if (!entityWithRoles || (checkAccess(user, getTypesOfUser(["admin", "developer"])) && !user.entities.map((x) => x.id).includes(id))) {
return {
redirect: {
destination: "/entities",
permanent: false,
},
};
}
const {entity, roles} = entityWithRoles;
const linkedUsers = await getLinkedUsers(user.id, user.type);
const entityUsers = await getEntityUsers(id);
const usersWithRole = entityUsers.map((u) => {
const e = u.entities.find((e) => e.id === id);
return {...u, role: roles.find((r) => r.id === e?.role)};
});
return {
props: {
user,
entity: JSON.parse(JSON.stringify(entity)),
roles: JSON.parse(JSON.stringify(roles)),
users: JSON.parse(JSON.stringify(usersWithRole)),
linkedUsers: JSON.parse(JSON.stringify(linkedUsers.users)),
},
};
}, sessionOptions);
type UserWithRole = User & {role?: Role};
interface Props {
user: User;
entity: Entity;
roles: Role[];
users: UserWithRole[];
linkedUsers: User[];
}
export default function Home({user, entity, roles, users, linkedUsers}: Props) {
const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const router = useRouter();
const allowEntityEdit = useMemo(() => checkAccess(user, ["admin", "developer"]), [user]);
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
const removeParticipants = () => {
if (selectedUsers.length === 0) return;
if (!allowEntityEdit) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} from this entity?`))
return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}/users`, {add: false, members: selectedUsers})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
setSelectedUsers([]);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const addParticipants = () => {
if (selectedUsers.length === 0) return;
if (!allowEntityEdit || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} to this entity?`)) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}/users`, {add: true, members: selectedUsers, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930"})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
setIsAdding(false);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const renameGroup = () => {
if (!allowEntityEdit) return;
const name = prompt("Rename this entity:", entity.label);
if (!name) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, {name})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const deleteGroup = () => {
if (!allowEntityEdit) return;
if (!confirm("Are you sure you want to delete this entity?")) return;
setIsLoading(true);
axios
.delete(`/api/entities/${entity.id}`)
.then(() => {
toast.success("This entity has been successfully deleted!");
router.replace("/entities");
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const renderCard = (u: UserWithRole) => {
return (
<button
onClick={() => toggleUser(u)}
disabled={!allowEntityEdit}
key={u.id}
className={clsx(
"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",
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">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]} {u.role && `- ${u.role.label}`}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
</span>
</div>
</button>
);
};
useEffect(() => setSelectedUsers([]), [isAdding]);
return (
<>
<Head>
<title>{entity.label} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
<div className="flex items-center gap-2">
<Link
href="/entities"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{entity.label}</h2>
</div>
</div>
{allowEntityEdit && !isAdding && (
<div className="flex items-center gap-2">
<button
onClick={renameGroup}
disabled={isLoading}
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 Entity</span>
</button>
<button
onClick={deleteGroup}
disabled={isLoading}
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">
<BsTrash />
<span className="text-xs">Delete Entity</span>
</button>
</div>
)}
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({users.length})</span>
{allowEntityEdit && !isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(true)}
disabled={isLoading}
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">
<BsPlus />
<span className="text-xs">Add Members</span>
</button>
<button
onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading}
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">
<BsTrash />
<span className="text-xs">Remove Members</span>
</button>
</div>
)}
{allowEntityEdit && isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(false)}
disabled={isLoading}
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">
<BsX />
<span className="text-xs">Discard Selection</span>
</button>
<button
onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading}
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">
<BsPlus />
<span className="text-xs">Add Members ({selectedUsers.length})</span>
</button>
</div>
)}
</div>
<CardList<User | UserWithRole>
list={isAdding ? linkedUsers : users}
renderCard={renderCard}
searchFields={[["name"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]}
/>
</section>
</Layout>
)}
</>
);
}

View File

@@ -0,0 +1,232 @@
/* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
import {GroupWithUsers, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import {getEntityWithRoles} from "@/utils/entities.be";
import {convertToUsers, getGroup} from "@/utils/groups.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {getUserName} from "@/utils/users";
import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import {
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus,
BsSquare,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({req, params}) => {
const user = req.session.user as User;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
const {id} = params as {id: string};
const entityWithRoles = await getEntityWithRoles(id);
if (!entityWithRoles || (checkAccess(user, getTypesOfUser(["admin", "developer"])) && !user.entities.map((x) => x.id).includes(id))) {
return {
redirect: {
destination: "/entities",
permanent: false,
},
};
}
const {entity, roles} = entityWithRoles;
const linkedUsers = await getLinkedUsers(user.id, user.type);
const users = await getEntityUsers(id);
return {
props: {
user,
entity: JSON.parse(JSON.stringify(entity)),
roles: JSON.parse(JSON.stringify(roles)),
users: JSON.parse(JSON.stringify(users)),
linkedUsers: JSON.parse(JSON.stringify(linkedUsers.users)),
},
};
}, sessionOptions);
interface Props {
user: User;
entity: Entity;
roles: Role[];
users: User[];
linkedUsers: User[];
}
export default function Home({user, entity, roles, users, linkedUsers}: Props) {
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const allowEntityEdit = useMemo(() => checkAccess(user, ["admin", "developer"]), [user]);
const renameGroup = () => {
if (!allowEntityEdit) return;
const name = prompt("Rename this entity:", entity.label);
if (!name) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, {name})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const deleteGroup = () => {
if (!allowEntityEdit) return;
if (!confirm("Are you sure you want to delete this entity?")) return;
setIsLoading(true);
axios
.delete(`/api/entities/${entity.id}`)
.then(() => {
toast.success("This entity has been successfully deleted!");
router.replace("/entities");
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const firstCard = () => (
<Link
href={`/entities/${entity.id}/role`}
className="p-4 border 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 Role</span>
</Link>
);
const renderCard = (role: Role) => {
const usersWithRole = users.filter((x) => x.entities.map((x) => x.role).includes(role.id));
return (
<button
disabled={!allowEntityEdit}
key={role.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
)}>
<div className="flex flex-col">
<span className="font-semibold">{role.label}</span>
<span className="opacity-80 text-sm">{usersWithRole.length} members</span>
</div>
<b>Permissions ({role.permissions.length}): </b>
<span>
{role.permissions.slice(0, 5).join(", ")}
{role.permissions.length > 5 ? <span className="opacity-60"> and {role.permissions.length - 5} more</span> : ""}
</span>
</button>
);
};
return (
<>
<Head>
<title>{entity.label} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
<div className="flex items-center gap-2">
<Link
href={`/entities/${entity.id}`}
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{entity.label}</h2>
</div>
</div>
{allowEntityEdit && !isEditing && (
<div className="flex items-center gap-2">
<button
onClick={renameGroup}
disabled={isLoading}
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 Entity</span>
</button>
<button
onClick={deleteGroup}
disabled={isLoading}
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">
<BsTrash />
<span className="text-xs">Delete Entity</span>
</button>
</div>
)}
</div>
<Divider />
<span className="font-semibold text-xl mb-4">Roles</span>
<CardList list={roles} searchFields={[["label"]]} renderCard={renderCard} firstCard={firstCard} />
</section>
</Layout>
)}
</>
);
}

View File

@@ -0,0 +1,116 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout";
import {GroupWithUsers, User} from "@/interfaces/user";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName} from "@/utils/users";
import {convertToUsers, getGroupsForUser} from "@/utils/groups.be";
import {countEntityUsers, getEntityUsers, getSpecificUsers} from "@/utils/users.be";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import Link from "next/link";
import {uniq} from "lodash";
import {BsPlus} from "react-icons/bs";
import CardList from "@/components/High/CardList";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {EntityWithRoles} from "@/interfaces/entity";
import Separator from "@/components/Low/Separator";
type EntitiesWithCount = {entity: EntityWithRoles; users: User[]; count: number};
export const getServerSideProps = withIronSessionSsr(async ({req}) => {
const user = req.session.user;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
const entities = await getEntitiesWithRoles(
checkAccess(user, getTypesOfUser(["admin", "developer"])) ? user.entities.map((x) => x.id) : undefined,
);
const entitiesWithCount = await Promise.all(
entities.map(async (e) => ({entity: e, count: await countEntityUsers(e.id), users: await getEntityUsers(e.id, 5)})),
);
return {
props: {user, entities: JSON.parse(JSON.stringify(entitiesWithCount))},
};
}, sessionOptions);
const SEARCH_FIELDS: string[][] = [["entity", "label"]];
interface Props {
user: User;
entities: EntitiesWithCount[];
}
export default function Home({user, entities}: Props) {
const renderCard = ({entity, users, count}: EntitiesWithCount) => (
<Link
href={`/entities/${entity.id}`}
key={entity.id}
className="p-4 border rounded-xl flex flex-col gap-2 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<span>
<b>Entity: </b>
{entity.label}
</span>
<b>Members ({count}): </b>
<span>
{users.map(getUserName).join(", ")}
{count > 5 ? <span className="opacity-60"> and {count - 5} more</span> : ""}
</span>
</Link>
);
const firstCard = () => (
<Link
href={`/entities/create`}
className="p-4 border 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 Entity</span>
</Link>
);
return (
<>
<Head>
<title>Entities | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="!gap-4">
<section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Entities</h2>
<Separator />
</div>
<CardList<EntitiesWithCount> list={entities} searchFields={SEARCH_FIELDS} renderCard={renderCard} firstCard={firstCard} />
</section>
</Layout>
)}
</>
);
}

View File

@@ -78,7 +78,7 @@ export default function Home({user, group, users}: Props) {
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],
[users, group.participants, group.admin.id, user.id],
);
const {rows, renderSearch} = useListSearch<User>(
@@ -122,7 +122,6 @@ export default function Home({user, group, users}: Props) {
setIsLoading(true);
console.log([...group.participants.map((x) => x.id), selectedUsers]);
axios
.patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]})
.then(() => {

View File

@@ -10,11 +10,10 @@ import {getUserName} from "@/utils/users";
import {convertToUsers, getGroupsForUser} from "@/utils/groups.be";
import {getSpecificUsers} from "@/utils/users.be";
import {checkAccess} from "@/utils/permissions";
import usePagination from "@/hooks/usePagination";
import {useListSearch} from "@/hooks/useListSearch";
import Link from "next/link";
import {uniq} from "lodash";
import {BsPlus} from "react-icons/bs";
import CardList from "@/components/High/CardList";
import Separator from "@/components/Low/Separator";
export const getServerSideProps = withIronSessionSsr(async ({req}) => {
@@ -51,24 +50,50 @@ export const getServerSideProps = withIronSessionSsr(async ({req}) => {
};
}, sessionOptions);
const SEARCH_FIELDS = [
["name"],
["admin", "name"],
["admin", "email"],
["admin", "corporateInformation", "companyInformation", "name"],
["participants", "name"],
["participants", "email"],
["participants", "corporateInformation", "companyInformation", "name"],
];
interface Props {
user: User;
groups: GroupWithUsers[];
}
export default function Home({user, groups}: Props) {
const {rows, renderSearch} = useListSearch(
[
["name"],
["admin", "name"],
["admin", "email"],
["admin", "corporateInformation", "companyInformation", "name"],
["participants", "name"],
["participants", "email"],
["participants", "corporateInformation", "companyInformation", "name"],
],
groups,
const renderCard = (group: GroupWithUsers) => (
<Link
href={`/groups/${group.id}`}
key={group.id}
className="p-4 border rounded-xl flex flex-col gap-2 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<span>
<b>Group: </b>
{group.name}
</span>
<span>
<b>Admin: </b>
{getUserName(group.admin)}
</span>
<b>Participants ({group.participants.length}): </b>
<span>
{group.participants.slice(0, 5).map(getUserName).join(", ")}
{group.participants.length > 5 ? <span className="opacity-60"> and {group.participants.length - 5} more</span> : ""}
</span>
</Link>
);
const firstCard = () => (
<Link
href={`/groups/create`}
className="p-4 border 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 Group</span>
</Link>
);
const {items, page, renderMinimal} = usePagination(rows, 20);
return (
<>
@@ -84,50 +109,13 @@ export default function Home({user, groups}: Props) {
<ToastContainer />
{user && (
<Layout user={user} className="!gap-4">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Groups</h2>
<Separator />
</div>
<section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Classrooms</h2>
<Separator />
</div>
<section className="flex flex-col gap-4 w-full">
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
<div className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{page === 0 && (
<Link
href={`/groups/create`}
className="p-4 border 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 Group</span>
</Link>
)}
{items.map((group) => (
<Link
href={`/groups/${group.id}`}
key={group.id}
className="p-4 border rounded-xl flex flex-col gap-2 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<span>
<b>Group: </b>
{group.name}
</span>
<span>
<b>Admin: </b>
{getUserName(group.admin)}
</span>
<b>Participants ({group.participants.length}): </b>
<span>
{group.participants.slice(0, 5).map(getUserName).join(", ")}
{group.participants.length > 5 ? (
<span className="opacity-60"> and {group.participants.length - 5} more</span>
) : (
""
)}
</span>
</Link>
))}
</div>
<CardList<GroupWithUsers> list={groups} searchFields={SEARCH_FIELDS} renderCard={renderCard} firstCard={firstCard} />
</section>
</Layout>
)}

View File

@@ -37,14 +37,7 @@ import {getUserCorporate} from "@/utils/groups.be";
import {getUsers} from "@/utils/users.be";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user;
const envVariables: {[key: string]: string} = {};
Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => {
envVariables[x] = process.env[x]!;
});
const user = req.session.user as User | undefined;
if (!user) {
return {
@@ -58,13 +51,12 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const linkedCorporate = (await getUserCorporate(user.id)) || null;
return {
props: {user, envVariables, linkedCorporate},
props: {user, linkedCorporate},
};
}, sessionOptions);
interface Props {
user: User;
envVariables: {[key: string]: string};
linkedCorporate?: CorporateUser | MasterCorporateUser;
}

View File

@@ -18,10 +18,18 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end
return await db.collection("assignments").find<Assignment>(query).toArray();
};
export const getAssignments = async () => {
return await db.collection("assignments").find<Assignment>({}).toArray();
};
export const getAssignmentsByAssignee = async (id: string, filter?: {[key in keyof Partial<Assignment>]: any}) => {
return await db
.collection("assignments")
.find<Assignment>({assignees: [id], ...(!filter ? {} : filter)})
.toArray();
};
export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => {
return await db.collection("assignments").find<Assignment>({assigner: id}).toArray();
};
@@ -37,6 +45,17 @@ export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date,
.toArray();
};
export const getEntityAssignments = async (id: string) => {
return await db.collection("assignments").find<Assignment>({entity: id}).toArray();
};
export const getEntitiesAssignments = async (ids: string[]) => {
return await db
.collection("assignments")
.find<Assignment>({entity: {$in: ids}})
.toArray();
};
export const getAssignmentsForCorporates = async (userType: Type, idsList: string[], startDate?: Date, endDate?: Date) => {
const assigners = await Promise.all(
idsList.map(async (id) => {

35
src/utils/entities.be.ts Normal file
View File

@@ -0,0 +1,35 @@
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
import client from "@/lib/mongodb";
import {getRolesByEntities, getRolesByEntity} from "./roles.be";
const db = client.db(process.env.MONGODB_DB);
export const getEntityWithRoles = async (id: string): Promise<{entity: Entity; roles: Role[]} | undefined> => {
const entity = await getEntity(id);
if (!entity) return undefined;
const roles = await getRolesByEntity(id);
return {entity, roles};
};
export const getEntity = async (id: string) => {
return (await db.collection("entities").findOne<Entity>({id})) ?? undefined;
};
export const getEntitiesWithRoles = async (ids?: string[]): Promise<EntityWithRoles[]> => {
const entities = await db
.collection("entities")
.find<Entity>(ids ? {id: {$in: ids}} : {})
.toArray();
const roles = await getRolesByEntities(entities.map((x) => x.id));
return entities.map((x) => ({...x, roles: roles.filter((y) => y.entityID === x.id) || []}));
};
export const getEntities = async (ids?: string[]) => {
return await db
.collection("entities")
.find<Entity>(ids ? {id: {$in: ids}} : {})
.toArray();
};

View File

@@ -1,5 +1,5 @@
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and} from "firebase/firestore";
import {shuffle} from "lodash";
import {groupBy, shuffle} from "lodash";
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
import {Module} from "@/interfaces";
@@ -29,6 +29,23 @@ export async function getSpecificExams(ids: string[]) {
return exams;
}
export const getExamsByIds = async (ids: {module: Module; id: string}[]) => {
const groupedByModule = groupBy(ids, "module");
const exams: Exam[] = (
await Promise.all(
Object.keys(groupedByModule).map(
async (m) =>
await db
.collection(m)
.find<Exam>({id: {$in: groupedByModule[m]}})
.toArray(),
),
)
).flat();
return exams;
};
export const getExams = async (
db: Db,
module: Module,

View File

@@ -20,3 +20,6 @@ export const getGradingSystem = async (user: User): Promise<Grading> => {
return {steps: CEFR_STEPS, user: user.id};
};
export const getGradingSystemByEntity = async (id: string) =>
(await db.collection("grading").findOne<Grading>({entity: id})) || {steps: CEFR_STEPS, user: ""};

View File

@@ -116,3 +116,11 @@ export const getCorporateNameForStudent = async (studentID: string) => {
return "";
};
export const getGroupsByEntity = async (id: string) => await db.collection("groups").find<Group>({entity: id}).toArray();
export const getGroupsByEntities = async (ids: string[]) =>
await db
.collection("groups")
.find<Group>({entity: {$in: ids}})
.toArray();

View File

@@ -43,3 +43,11 @@ export const convertBase64 = (file: File) => {
};
});
};
export const mapBy = <T>(obj: T[], key: keyof T) => {
if (!obj) return undefined;
return obj.map((i) => i[key]);
};
export const filterBy = <T>(obj: T[], key: keyof T, value: any) => obj.filter((i) => i[key] === value);
export const serialize = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));

18
src/utils/invites.be.ts Normal file
View File

@@ -0,0 +1,18 @@
import {Session} from "@/hooks/useSessions";
import {Invite, InviteWithUsers} from "@/interfaces/invite";
import {User} from "@/interfaces/user";
import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
export const getInvitesByInvitee = async (id: string, limit?: number) =>
await db
.collection("invites")
.find<Invite>({to: id})
.limit(limit || 0)
.toArray();
export const convertInvitersToUsers = async (invite: Invite): Promise<InviteWithUsers> => ({
...invite,
from: (await db.collection("users").findOne<User>({id: invite.from})) ?? undefined,
});

14
src/utils/roles.be.ts Normal file
View File

@@ -0,0 +1,14 @@
import {Role} from "@/interfaces/entity";
import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
export const getRolesByEntities = async (entityIDs: string[]) =>
await db
.collection("roles")
.find<Role>({entityID: {$in: entityIDs}})
.toArray();
export const getRolesByEntity = async (entityID: string) => await db.collection("roles").find<Role>({entityID}).toArray();
export const getRole = async (id: string) => (await db.collection("roles").findOne<Role>({id})) ?? undefined;

11
src/utils/sessions.be.ts Normal file
View File

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

12
src/utils/stats.be.ts Normal file
View File

@@ -0,0 +1,12 @@
import {Stat} from "@/interfaces/user";
import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
export const getStatsByUser = async (id: string) => await db.collection("stats").find<Stat>({user: id}).toArray();
export const getStatsByUsers = async (ids: string[]) =>
await db
.collection("stats")
.find<Stat>({user: {$in: ids}})
.toArray();

View File

@@ -1,9 +1,11 @@
import {CorporateUser, Group, Type, User} from "@/interfaces/user";
import {CorporateUser, Type, User} from "@/interfaces/user";
import {getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups} from "./groups.be";
import {last, uniq, uniqBy} from "lodash";
import {uniq} from "lodash";
import {getUserCodes} from "./codes.be";
import moment from "moment";
import client from "@/lib/mongodb";
import {WithEntity} from "@/interfaces/entity";
import {getEntity} from "./entities.be";
import {getRole} from "./roles.be";
const db = client.db(process.env.MONGODB_DB);
@@ -14,6 +16,22 @@ export async function getUsers() {
.toArray();
}
export async function getUserWithEntity(id: string): Promise<WithEntity<User> | undefined> {
const user = await db.collection("users").findOne<User>({id: id}, {projection: {_id: 0}});
if (!user) return undefined;
const entities = await Promise.all(
user.entities.map(async (e) => {
const entity = await getEntity(e.id);
const role = await getRole(e.role);
return {entity, role};
}),
);
return {...user, entities};
}
export async function getUser(id: string): Promise<User | undefined> {
const user = await db.collection("users").findOne<User>({id: id}, {projection: {_id: 0}});
return !!user ? user : undefined;
@@ -28,6 +46,30 @@ export async function getSpecificUsers(ids: string[]) {
.toArray();
}
export async function getEntityUsers(id: string, limit?: number) {
return await db
.collection("users")
.find<User>({"entities.id": id})
.limit(limit || 0)
.toArray();
}
export async function countEntityUsers(id: string) {
return await db.collection("users").countDocuments({"entities.id": id});
}
export async function getEntitiesUsers(ids: string[], limit?: number) {
return await db
.collection("users")
.find<User>({"entities.id": {$in: ids}})
.limit(limit || 0)
.toArray();
}
export async function countEntitiesUsers(ids: string[]) {
return await db.collection("users").countDocuments({"entities.id": {$in: ids}});
}
export async function getLinkedUsers(
userID?: string,
userType?: Type,