Continued creating the entity system
This commit is contained in:
32
src/components/High/CardList.tsx
Normal file
32
src/components/High/CardList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/Medium/InviteWithUserCard.tsx
Normal file
67
src/components/Medium/InviteWithUserCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,61 +1,40 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
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 {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter, mapBy} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
BsClipboard2DataFill,
|
|
||||||
BsClock,
|
BsClock,
|
||||||
BsGlobeCentralSouthAsia,
|
|
||||||
BsPaperclip,
|
BsPaperclip,
|
||||||
BsPerson,
|
|
||||||
BsPersonAdd,
|
|
||||||
BsPersonFill,
|
BsPersonFill,
|
||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsPersonGear,
|
|
||||||
BsPencilSquare,
|
BsPencilSquare,
|
||||||
BsPersonBadge,
|
|
||||||
BsPersonCheck,
|
BsPersonCheck,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
BsArrowRepeat,
|
|
||||||
BsPlus,
|
|
||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsDatabase,
|
BsDatabase,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {groupByExam} from "@/utils/stats";
|
import {groupByExam} from "@/utils/stats";
|
||||||
import IconCard from "../IconCard";
|
import IconCard from "../IconCard";
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
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 useUserBalance from "@/hooks/useUserBalance";
|
||||||
import AssignmentsPage from "../views/AssignmentsPage";
|
import AssignmentsPage from "../views/AssignmentsPage";
|
||||||
import StudentPerformancePage from "./StudentPerformancePage";
|
import StudentPerformancePage from "./StudentPerformancePage";
|
||||||
import MasterStatistical from "../MasterCorporate/MasterStatistical";
|
|
||||||
import MasterStatisticalPage from "./MasterStatisticalPage";
|
import MasterStatisticalPage from "./MasterStatisticalPage";
|
||||||
|
import {getEntitiesUsers} from "@/utils/users.be";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: CorporateUser;
|
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 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(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && router.asPath === "/#");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, router.asPath]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={clsx(
|
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",
|
tooltip && "tooltip tooltip-bottom",
|
||||||
isSelected && `border border-solid border-${colorClasses[color]}`,
|
isSelected && `border border-solid border-${colorClasses[color]}`,
|
||||||
className,
|
className,
|
||||||
@@ -38,6 +38,6 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c
|
|||||||
{isLoading ? "..." : value}
|
{isLoading ? "..." : value}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ export interface Entity {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Roles {
|
export interface Role {
|
||||||
id: string;
|
id: string;
|
||||||
|
entityID: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntityWithPermissions extends Entity {
|
export interface EntityWithRoles extends Entity {
|
||||||
roles: Roles[];
|
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;
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export interface Step {
|
|||||||
|
|
||||||
export interface Grading {
|
export interface Grading {
|
||||||
user: string;
|
user: string;
|
||||||
|
entity?: string;
|
||||||
steps: Step[];
|
steps: Step[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import {User} from "./user";
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
id: string;
|
id: string;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteWithUsers extends Omit<Invite, "from"> {
|
||||||
|
from?: User;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface Assignment {
|
|||||||
start?: boolean;
|
start?: boolean;
|
||||||
autoStartDate?: Date;
|
autoStartDate?: Date;
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
|
entity?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssignmentWithCorporateId = Assignment & {corporateId: string};
|
export type AssignmentWithCorporateId = Assignment & {corporateId: string};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface BasicUser {
|
|||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
entities: string[];
|
entities: {id: string; role: string}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentUser extends BasicUser {
|
export interface StudentUser extends BasicUser {
|
||||||
@@ -150,6 +150,7 @@ export interface Group {
|
|||||||
participants: string[];
|
participants: string[];
|
||||||
id: string;
|
id: string;
|
||||||
disableEditing?: boolean;
|
disableEditing?: boolean;
|
||||||
|
entity?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupWithUsers extends Omit<Group, "participants" | "admin"> {
|
export interface GroupWithUsers extends Omit<Group, "participants" | "admin"> {
|
||||||
|
|||||||
46
src/pages/api/entities/[id]/index.ts
Normal file
46
src/pages/api/entities/[id]/index.ts
Normal 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});
|
||||||
|
}
|
||||||
65
src/pages/api/entities/[id]/users.ts
Normal file
65
src/pages/api/entities/[id]/users.ts
Normal 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();
|
||||||
|
}
|
||||||
48
src/pages/api/entities/index.ts
Normal file
48
src/pages/api/entities/index.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// 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 = {
|
type Data = {
|
||||||
name: string
|
name: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function handler(
|
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
|
||||||
req: NextApiRequest,
|
await db.collection("users").updateMany({}, {$set: {entities: []}});
|
||||||
res: NextApiResponse<Data>
|
|
||||||
) {
|
res.status(200).json({name: "John Doe"});
|
||||||
res.status(200).json({ name: 'John Doe' })
|
|
||||||
}
|
}
|
||||||
|
|||||||
192
src/pages/dashboard/admin.tsx
Normal file
192
src/pages/dashboard/admin.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/pages/dashboard/corporate.tsx
Normal file
208
src/pages/dashboard/corporate.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/pages/dashboard/developer.tsx
Normal file
192
src/pages/dashboard/developer.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/pages/dashboard/index.tsx
Normal file
27
src/pages/dashboard/index.tsx
Normal 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>;
|
||||||
|
}
|
||||||
198
src/pages/dashboard/mastercorporate.tsx
Normal file
198
src/pages/dashboard/mastercorporate.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
src/pages/dashboard/student.tsx
Normal file
298
src/pages/dashboard/student.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/pages/dashboard/teacher.tsx
Normal file
182
src/pages/dashboard/teacher.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
343
src/pages/entities/[id]/index.tsx
Normal file
343
src/pages/entities/[id]/index.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
src/pages/entities/[id]/settings.tsx
Normal file
232
src/pages/entities/[id]/settings.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/pages/entities/index.tsx
Normal file
116
src/pages/entities/index.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ export default function Home({user, group, users}: Props) {
|
|||||||
|
|
||||||
const nonParticipantUsers = useMemo(
|
const nonParticipantUsers = useMemo(
|
||||||
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)),
|
() => 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>(
|
const {rows, renderSearch} = useListSearch<User>(
|
||||||
@@ -122,7 +122,6 @@ export default function Home({user, group, users}: Props) {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
console.log([...group.participants.map((x) => x.id), selectedUsers]);
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]})
|
.patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import {getUserName} from "@/utils/users";
|
|||||||
import {convertToUsers, getGroupsForUser} from "@/utils/groups.be";
|
import {convertToUsers, getGroupsForUser} from "@/utils/groups.be";
|
||||||
import {getSpecificUsers} from "@/utils/users.be";
|
import {getSpecificUsers} from "@/utils/users.be";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import usePagination from "@/hooks/usePagination";
|
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {uniq} from "lodash";
|
import {uniq} from "lodash";
|
||||||
import {BsPlus} from "react-icons/bs";
|
import {BsPlus} from "react-icons/bs";
|
||||||
|
import CardList from "@/components/High/CardList";
|
||||||
import Separator from "@/components/Low/Separator";
|
import Separator from "@/components/Low/Separator";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req}) => {
|
||||||
@@ -51,13 +50,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req}) => {
|
|||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
const SEARCH_FIELDS = [
|
||||||
user: User;
|
|
||||||
groups: GroupWithUsers[];
|
|
||||||
}
|
|
||||||
export default function Home({user, groups}: Props) {
|
|
||||||
const {rows, renderSearch} = useListSearch(
|
|
||||||
[
|
|
||||||
["name"],
|
["name"],
|
||||||
["admin", "name"],
|
["admin", "name"],
|
||||||
["admin", "email"],
|
["admin", "email"],
|
||||||
@@ -65,45 +58,14 @@ export default function Home({user, groups}: Props) {
|
|||||||
["participants", "name"],
|
["participants", "name"],
|
||||||
["participants", "email"],
|
["participants", "email"],
|
||||||
["participants", "corporateInformation", "companyInformation", "name"],
|
["participants", "corporateInformation", "companyInformation", "name"],
|
||||||
],
|
];
|
||||||
groups,
|
|
||||||
);
|
|
||||||
const {items, page, renderMinimal} = usePagination(rows, 20);
|
|
||||||
|
|
||||||
return (
|
interface Props {
|
||||||
<>
|
user: User;
|
||||||
<Head>
|
groups: GroupWithUsers[];
|
||||||
<title>Groups | EnCoach</title>
|
}
|
||||||
<meta
|
export default function Home({user, groups}: Props) {
|
||||||
name="description"
|
const renderCard = (group: GroupWithUsers) => (
|
||||||
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">
|
|
||||||
<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">
|
|
||||||
<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
|
<Link
|
||||||
href={`/groups/${group.id}`}
|
href={`/groups/${group.id}`}
|
||||||
key={group.id}
|
key={group.id}
|
||||||
@@ -119,15 +81,41 @@ export default function Home({user, groups}: Props) {
|
|||||||
<b>Participants ({group.participants.length}): </b>
|
<b>Participants ({group.participants.length}): </b>
|
||||||
<span>
|
<span>
|
||||||
{group.participants.slice(0, 5).map(getUserName).join(", ")}
|
{group.participants.slice(0, 5).map(getUserName).join(", ")}
|
||||||
{group.participants.length > 5 ? (
|
{group.participants.length > 5 ? <span className="opacity-60"> and {group.participants.length - 5} more</span> : ""}
|
||||||
<span className="opacity-60"> and {group.participants.length - 5} more</span>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Groups | 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">Classrooms</h2>
|
||||||
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CardList<GroupWithUsers> list={groups} searchFields={SEARCH_FIELDS} renderCard={renderCard} firstCard={firstCard} />
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,14 +37,7 @@ import {getUserCorporate} from "@/utils/groups.be";
|
|||||||
import {getUsers} from "@/utils/users.be";
|
import {getUsers} from "@/utils/users.be";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user as User | undefined;
|
||||||
|
|
||||||
const envVariables: {[key: string]: string} = {};
|
|
||||||
Object.keys(process.env)
|
|
||||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
|
||||||
.forEach((x: string) => {
|
|
||||||
envVariables[x] = process.env[x]!;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -58,13 +51,12 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
const linkedCorporate = (await getUserCorporate(user.id)) || null;
|
const linkedCorporate = (await getUserCorporate(user.id)) || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user, envVariables, linkedCorporate},
|
props: {user, linkedCorporate},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
envVariables: {[key: string]: string};
|
|
||||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,18 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end
|
|||||||
|
|
||||||
return await db.collection("assignments").find<Assignment>(query).toArray();
|
return await db.collection("assignments").find<Assignment>(query).toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAssignments = async () => {
|
export const getAssignments = async () => {
|
||||||
return await db.collection("assignments").find<Assignment>({}).toArray();
|
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) => {
|
export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => {
|
||||||
return await db.collection("assignments").find<Assignment>({assigner: id}).toArray();
|
return await db.collection("assignments").find<Assignment>({assigner: id}).toArray();
|
||||||
};
|
};
|
||||||
@@ -37,6 +45,17 @@ export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date,
|
|||||||
.toArray();
|
.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) => {
|
export const getAssignmentsForCorporates = async (userType: Type, idsList: string[], startDate?: Date, endDate?: Date) => {
|
||||||
const assigners = await Promise.all(
|
const assigners = await Promise.all(
|
||||||
idsList.map(async (id) => {
|
idsList.map(async (id) => {
|
||||||
|
|||||||
35
src/utils/entities.be.ts
Normal file
35
src/utils/entities.be.ts
Normal 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();
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and} from "firebase/firestore";
|
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 {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
||||||
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
@@ -29,6 +29,23 @@ export async function getSpecificExams(ids: string[]) {
|
|||||||
return exams;
|
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 (
|
export const getExams = async (
|
||||||
db: Db,
|
db: Db,
|
||||||
module: Module,
|
module: Module,
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ export const getGradingSystem = async (user: User): Promise<Grading> => {
|
|||||||
|
|
||||||
return {steps: CEFR_STEPS, user: user.id};
|
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: ""};
|
||||||
|
|||||||
@@ -116,3 +116,11 @@ export const getCorporateNameForStudent = async (studentID: string) => {
|
|||||||
|
|
||||||
return "";
|
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();
|
||||||
|
|||||||
@@ -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
18
src/utils/invites.be.ts
Normal 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
14
src/utils/roles.be.ts
Normal 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
11
src/utils/sessions.be.ts
Normal 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
12
src/utils/stats.be.ts
Normal 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();
|
||||||
@@ -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 {getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups} from "./groups.be";
|
||||||
import {last, uniq, uniqBy} from "lodash";
|
import {uniq} from "lodash";
|
||||||
import {getUserCodes} from "./codes.be";
|
import {getUserCodes} from "./codes.be";
|
||||||
import moment from "moment";
|
|
||||||
import client from "@/lib/mongodb";
|
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);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -14,6 +16,22 @@ export async function getUsers() {
|
|||||||
.toArray();
|
.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> {
|
export async function getUser(id: string): Promise<User | undefined> {
|
||||||
const user = await db.collection("users").findOne<User>({id: id}, {projection: {_id: 0}});
|
const user = await db.collection("users").findOne<User>({id: id}, {projection: {_id: 0}});
|
||||||
return !!user ? user : undefined;
|
return !!user ? user : undefined;
|
||||||
@@ -28,6 +46,30 @@ export async function getSpecificUsers(ids: string[]) {
|
|||||||
.toArray();
|
.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(
|
export async function getLinkedUsers(
|
||||||
userID?: string,
|
userID?: string,
|
||||||
userType?: Type,
|
userType?: Type,
|
||||||
|
|||||||
Reference in New Issue
Block a user