Merged in ENCOA-316-ENCOA-317 (pull request #139)

ENCOA-316 ENCOA-317

Approved-by: Tiago Ribeiro
This commit is contained in:
Francisco Lima
2025-01-27 09:27:09 +00:00
committed by Tiago Ribeiro
56 changed files with 4452 additions and 2988 deletions

View File

@@ -1,4 +1,3 @@
import useEntities from "@/hooks/useEntities";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
@@ -6,34 +5,92 @@ import { useRouter } from "next/router";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Navbar from "../Navbar"; import Navbar from "../Navbar";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
import React, { useEffect, useState } from "react";
export const LayoutContext = React.createContext({
onFocusLayerMouseEnter: () => {},
setOnFocusLayerMouseEnter: (() => {}) as React.Dispatch<
React.SetStateAction<() => void>
>,
navDisabled: false,
setNavDisabled: (() => {}) as React.Dispatch<React.SetStateAction<boolean>>,
focusMode: false,
setFocusMode: (() => {}) as React.Dispatch<React.SetStateAction<boolean>>,
hideSidebar: false,
setHideSidebar: (() => {}) as React.Dispatch<React.SetStateAction<boolean>>,
bgColor: "bg-white",
setBgColor: (() => {}) as React.Dispatch<React.SetStateAction<string>>,
className: "",
setClassName: (() => {}) as React.Dispatch<React.SetStateAction<string>>,
});
interface Props { interface Props {
user: User; user: User;
entities?: EntityWithRoles[] entities?: EntityWithRoles[];
children: React.ReactNode; children: React.ReactNode;
className?: string; refreshPage?: boolean;
navDisabled?: boolean;
focusMode?: boolean;
hideSidebar?: boolean
bgColor?: string;
onFocusLayerMouseEnter?: () => void;
} }
export default function Layout({ export default function Layout({
user, user,
entities,
children, children,
className, refreshPage,
bgColor = "bg-white",
hideSidebar,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter
}: Props) { }: Props) {
const [onFocusLayerMouseEnter, setOnFocusLayerMouseEnter] = useState(
() => () => {}
);
const [navDisabled, setNavDisabled] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [hideSidebar, setHideSidebar] = useState(false);
const [bgColor, setBgColor] = useState("bg-white");
const [className, setClassName] = useState("");
useEffect(() => {
if (refreshPage) {
setClassName("");
setBgColor("bg-white");
setFocusMode(false);
setHideSidebar(false);
setNavDisabled(false);
setOnFocusLayerMouseEnter(() => () => {});
}
}, [refreshPage]);
const LayoutContextValue = React.useMemo(
() => ({
onFocusLayerMouseEnter,
setOnFocusLayerMouseEnter,
navDisabled,
setNavDisabled,
focusMode,
setFocusMode,
hideSidebar,
setHideSidebar,
bgColor,
setBgColor,
className,
setClassName,
}),
[
bgColor,
className,
focusMode,
hideSidebar,
navDisabled,
onFocusLayerMouseEnter,
]
);
const router = useRouter(); const router = useRouter();
const { entities } = useEntities()
return ( return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}> <LayoutContext.Provider value={LayoutContextValue}>
<main
className={clsx(
"w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative"
)}
>
<ToastContainer /> <ToastContainer />
{!hideSidebar && user && ( {!hideSidebar && user && (
<Navbar <Navbar
@@ -61,11 +118,13 @@ export default function Layout({
`w-full min-h-full ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`, `w-full min-h-full ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
bgColor !== "bg-white" ? "justify-center" : "h-fit", bgColor !== "bg-white" ? "justify-center" : "h-fit",
hideSidebar ? "md:mx-8" : "md:mr-8", hideSidebar ? "md:mx-8" : "md:mr-8",
className, className
)}> )}
>
{children} {children}
</div> </div>
</div> </div>
</main> </main>
</LayoutContext.Provider>
); );
} }

View File

@@ -48,6 +48,15 @@ export default function AsyncSelect({
flat, flat,
}: Props & (MultiProps | SingleProps)) { }: Props & (MultiProps | SingleProps)) {
const [target, setTarget] = useState<HTMLElement>(); const [target, setTarget] = useState<HTMLElement>();
const [inputValue, setInputValue] = useState("");
//Implemented a debounce to prevent the API from being called too frequently
useEffect(() => {
const timer = setTimeout(() => {
loadOptions(inputValue);
}, 200);
return () => clearTimeout(timer);
}, [inputValue, loadOptions]);
useEffect(() => { useEffect(() => {
if (document) setTarget(document.body); if (document) setTarget(document.body);
@@ -77,7 +86,7 @@ export default function AsyncSelect({
filterOption={null} filterOption={null}
loadingMessage={() => "Loading..."} loadingMessage={() => "Loading..."}
onInputChange={(inputValue) => { onInputChange={(inputValue) => {
loadOptions(inputValue); setInputValue(inputValue);
}} }}
options={options} options={options}
value={value} value={value}

View File

@@ -0,0 +1,60 @@
import React from "react";
export default function UserProfileSkeleton() {
return (
<div className="bg-white min-h-screen p-6">
<div className="mt-6 bg-white p-6 rounded-lg flex gap-4 items-center">
<div className="h-64 w-60 bg-gray-300 animate-pulse rounded"></div>
<div className="flex-1">
<div className="h-12 w-64 bg-gray-300 animate-pulse rounded"></div>
<div className="flex justify-between items-center mt-1">
<div className="h-4 w-60 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-8 w-32 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
<div className="h-4 w-100 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="mt-6 grid grid-cols-4 justify-item-start gap-4">
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-6 bg-white p-6 rounded-lg">
<div className="h-6 w-40 bg-gray-300 animate-pulse rounded mb-4"></div>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex justify-between items-center">
<div className="h-4 w-24 bg-gray-300 animate-pulse rounded"></div>
<div className="h-2 w-3/4 bg-gray-300 animate-pulse rounded"></div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { MdSpaceDashboard } from "react-icons/md";
import { import {
BsFileEarmarkText, BsFileEarmarkText,
BsClockHistory, BsClockHistory,
BsPencil,
BsGraphUp, BsGraphUp,
BsChevronBarRight, BsChevronBarRight,
BsChevronBarLeft, BsChevronBarLeft,
@@ -24,11 +23,15 @@ import { preventNavigation } from "@/utils/navigation.disabled";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener"; import useTicketsListener from "@/hooks/useTicketsListener";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { getTypesOfUser } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { useAllowedEntities, useAllowedEntitiesSomePermissions } from "@/hooks/useEntityPermissions"; import {
useAllowedEntities,
useAllowedEntitiesSomePermissions,
} from "@/hooks/useEntityPermissions";
import { useMemo } from "react"; import { useMemo } from "react";
import { PermissionType } from "../interfaces/permissions";
interface Props { interface Props {
path: string; path: string;
@@ -37,7 +40,7 @@ interface Props {
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
user: User; user: User;
entities?: EntityWithRoles[] entities?: EntityWithRoles[];
} }
interface NavProps { interface NavProps {
@@ -50,17 +53,28 @@ interface NavProps {
badge?: number; badge?: number;
} }
const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => { const Nav = ({
Icon,
label,
path,
keyPath,
disabled = false,
isMinimized = false,
badge,
}: NavProps) => {
return ( return (
<Link <Link
href={!disabled ? keyPath : ""} href={!disabled ? keyPath : ""}
className={clsx( className={clsx(
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out relative", "transition-all duration-300 ease-in-out relative",
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer", disabled
? "hover:bg-mti-gray-dim cursor-not-allowed"
: "hover:bg-mti-purple-light cursor-pointer",
path.startsWith(keyPath) && "bg-mti-purple-light text-white", path.startsWith(keyPath) && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]"
)}> )}
>
<Icon size={24} /> <Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
{!!badge && badge > 0 && ( {!!badge && badge > 0 && (
@@ -68,8 +82,9 @@ const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false
className={clsx( className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white", "bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0", isMinimized && "absolute right-0 top-0"
)}> )}
>
{badge} {badge}
</div> </div>
)} )}
@@ -84,23 +99,154 @@ export default function Sidebar({
focusMode = false, focusMode = false,
user, user,
onFocusLayerMouseEnter, onFocusLayerMouseEnter,
className className,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
const isAdmin = useMemo(() => ['developer', 'admin'].includes(user?.type), [user?.type]) const isAdmin = useMemo(
() => ["developer", "admin"].includes(user?.type),
[user?.type]
);
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
state.isSidebarMinimized,
state.toggleSidebarMinimized,
]);
const { totalAssignedTickets } = useTicketsListener(user.id);
const { permissions } = usePermissions(user.id); const { permissions } = usePermissions(user.id);
const entitiesAllowStatistics = useAllowedEntities(user, entities, "view_statistics") const entitiesAllowStatistics = useAllowedEntities(
const entitiesAllowPaymentRecord = useAllowedEntities(user, entities, "view_payment_record") user,
entities,
"view_statistics"
);
const entitiesAllowPaymentRecord = useAllowedEntities(
user,
entities,
"view_payment_record"
);
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [ const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level" user,
]) entities,
[
"generate_reading",
"generate_listening",
"generate_writing",
"generate_speaking",
"generate_level",
]
);
const sidebarPermissions = useMemo<{ [key: string]: boolean }>(() => {
if (user.type === "developer") {
return {
viewExams: true,
viewStats: true,
viewRecords: true,
viewTickets: true,
viewClassrooms: true,
viewSettings: true,
viewPaymentRecord: true,
viewGeneration: true,
};
}
const sidebarPermissions: { [key: string]: boolean } = {
viewExams: false,
viewStats: false,
viewRecords: false,
viewTickets: false,
viewClassrooms: false,
viewSettings: false,
viewPaymentRecord: false,
viewGeneration: false,
};
if (!user || !user?.type) return sidebarPermissions;
const neededPermissions = permissions.reduce((acc, curr) => {
if (
["viewExams", "viewRecords", "viewTickets"].includes(curr as string)
) {
acc.push(curr);
}
return acc;
}, [] as PermissionType[]);
if (
["student", "teacher", "developer"].includes(user.type) &&
neededPermissions.includes("viewExams")
) {
sidebarPermissions["viewExams"] = true;
}
if (
getTypesOfUser(["agent"]).includes(user.type) &&
(entitiesAllowStatistics.length > 0 ||
neededPermissions.includes("viewStats"))
) {
sidebarPermissions["viewStats"] = true;
}
if (
[
"admin",
"developer",
"teacher",
"corporate",
"mastercorporate",
].includes(user.type) &&
(entitiesAllowGeneration.length > 0 || isAdmin)
) {
sidebarPermissions["viewGeneration"] = true;
}
if (
getTypesOfUser(["agent"]).includes(user.type) &&
neededPermissions.includes("viewRecords")
) {
sidebarPermissions["viewRecords"] = true;
}
if (
["admin", "developer", "agent"].includes(user.type) &&
neededPermissions.includes("viewTickets")
) {
sidebarPermissions["viewTickets"] = true;
}
if (
[
"admin",
"mastercorporate",
"developer",
"corporate",
"teacher",
"student",
].includes(user.type)
) {
sidebarPermissions["viewClassrooms"] = true;
}
if (getTypesOfUser(["student", "agent"]).includes(user.type)) {
sidebarPermissions["viewSettings"] = true;
}
if (
["admin", "developer", "agent", "corporate", "mastercorporate"].includes(
user.type
) &&
entitiesAllowPaymentRecord.length > 0
) {
sidebarPermissions["viewPaymentRecord"] = true;
}
return sidebarPermissions;
}, [
entitiesAllowGeneration.length,
entitiesAllowPaymentRecord.length,
entitiesAllowStatistics.length,
isAdmin,
permissions,
user,
]);
const { totalAssignedTickets } = useTicketsListener(
user.id,
sidebarPermissions["viewTickets"]
);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
@@ -115,17 +261,39 @@ export default function Sidebar({
className={clsx( className={clsx(
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8", "relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6", isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className, className
)}> )}
>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/dashboard" isMinimized={isMinimized} /> <Nav
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && ( disabled={disableNavigation}
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Practice" path={path} keyPath="/exam" isMinimized={isMinimized} /> Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/dashboard"
isMinimized={isMinimized}
/>
{sidebarPermissions["viewExams"] && (
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Practice"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"])) && entitiesAllowStatistics.length > 0 && ( {sidebarPermissions["viewStats"] && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> <Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, ["developer", "admin", "mastercorporate", "corporate", "teacher", "student"], permissions) && ( {sidebarPermissions["viewClassrooms"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsPeople} Icon={BsPeople}
@@ -135,13 +303,27 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> <Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} /> <Nav
disabled={disableNavigation}
Icon={CiDumbbell}
label="Training"
path={path}
keyPath="/training"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"]) && entitiesAllowPaymentRecord.length > 0 && ( {sidebarPermissions["viewPaymentRecords"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -151,7 +333,7 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && ( {sidebarPermissions["viewSettings"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsShieldFill} Icon={BsShieldFill}
@@ -161,7 +343,7 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && ( {sidebarPermissions["viewTickets"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsClipboardData} Icon={BsClipboardData}
@@ -172,8 +354,7 @@ export default function Sidebar({
badge={totalAssignedTickets} badge={totalAssignedTickets}
/> />
)} )}
{checkAccess(user, ["admin", "developer", "teacher", 'corporate', 'mastercorporate']) {sidebarPermissions["viewGeneration"] && (
&& (entitiesAllowGeneration.length > 0 || isAdmin) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCloudFill} Icon={BsCloudFill}
@@ -185,21 +366,63 @@ export default function Sidebar({
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized /> <Nav
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized /> disabled={disableNavigation}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && ( Icon={MdSpaceDashboard}
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized /> label="Dashboard"
path={path}
keyPath="/"
isMinimized
/>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized
/>
{sidebarPermissions["viewStats"] && (
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized /> <Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized /> <Nav
disabled={disableNavigation}
Icon={CiDumbbell}
label="Training"
path={path}
keyPath="/training"
isMinimized
/>
)} )}
{checkAccess(user, getTypesOfUser(["student"])) && ( {sidebarPermissions["viewSettings"] && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized /> <Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized
/>
)} )}
{entitiesAllowGeneration.length > 0 && ( {sidebarPermissions["viewGeneration"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCloudFill} Icon={BsCloudFill}
@@ -218,10 +441,17 @@ export default function Sidebar({
onClick={toggleMinimize} onClick={toggleMinimize}
className={clsx( className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)}> )}
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />} >
{!isMinimized && <span className="text-lg font-medium">Minimize</span>} {isMinimized ? (
<BsChevronBarRight size={24} />
) : (
<BsChevronBarLeft size={24} />
)}
{!isMinimized && (
<span className="text-lg font-medium">Minimize</span>
)}
</div> </div>
<div <div
role="button" role="button"
@@ -229,13 +459,18 @@ export default function Sidebar({
onClick={focusMode ? () => {} : logout} onClick={focusMode ? () => {} : logout}
className={clsx( className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)}> )}
>
<RiLogoutBoxFill size={24} /> <RiLogoutBoxFill size={24} />
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>} {!isMinimized && (
<span className="-xl:hidden text-lg font-medium">Log Out</span>
)}
</div> </div>
</div> </div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} {focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</section> </section>
); );
} }

View File

@@ -14,8 +14,6 @@ import {
BsPen, BsPen,
BsXCircle, BsXCircle,
} from "react-icons/bs"; } from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { sortByModuleName } from "@/utils/moduleUtils"; import { sortByModuleName } from "@/utils/moduleUtils";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
@@ -24,7 +22,7 @@ import { Variant } from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions"; import useSessions, { Session } from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import moment from "moment"; import useStats from "../hooks/useStats";
interface Props { interface Props {
user: User; user: User;
@@ -41,7 +39,21 @@ export default function Selection({ user, page, onStart }: Props) {
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const { data: stats } = useFilterRecordsByUser<Stat[]>(user?.id); const {
data: {
allStats = [],
moduleCount: { reading, listening, writing, speaking, level } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
},
},
} = useStats<{
allStats: Stat[];
moduleCount: Record<Module, number>;
}>(user?.id, !user?.id, "byModule");
const { sessions, isLoading, reload } = useSessions(user.id); const { sessions, isLoading, reload } = useSessions(user.id);
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
@@ -77,7 +89,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" /> <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Reading", label: "Reading",
value: totalExamsByModule(stats, "reading"), value: reading,
tooltip: "The amount of reading exams performed.", tooltip: "The amount of reading exams performed.",
}, },
{ {
@@ -85,7 +97,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" /> <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Listening", label: "Listening",
value: totalExamsByModule(stats, "listening"), value: listening,
tooltip: "The amount of listening exams performed.", tooltip: "The amount of listening exams performed.",
}, },
{ {
@@ -93,7 +105,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" /> <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Writing", label: "Writing",
value: totalExamsByModule(stats, "writing"), value: writing,
tooltip: "The amount of writing exams performed.", tooltip: "The amount of writing exams performed.",
}, },
{ {
@@ -101,7 +113,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" /> <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Speaking", label: "Speaking",
value: totalExamsByModule(stats, "speaking"), value: speaking,
tooltip: "The amount of speaking exams performed.", tooltip: "The amount of speaking exams performed.",
}, },
{ {
@@ -109,7 +121,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" /> <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Level", label: "Level",
value: totalExamsByModule(stats, "level"), value: level,
tooltip: "The amount of level exams performed.", tooltip: "The amount of level exams performed.",
}, },
]} ]}

View File

@@ -1,23 +1,22 @@
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Discount } from "@/interfaces/paypal";
import { Code, Group, User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
export default function useEntities() { export default function useEntities(shouldNot?: boolean) {
const [entities, setEntities] = useState<EntityWithRoles[]>([]); const [entities, setEntities] = useState<EntityWithRoles[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const getData = () => { const getData = useCallback(() => {
if (shouldNot) return;
setIsLoading(true); setIsLoading(true);
axios axios
.get<EntityWithRoles[]>("/api/entities?showRoles=true") .get<EntityWithRoles[]>("/api/entities?showRoles=true")
.then((response) => setEntities(response.data)) .then((response) => setEntities(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; }, [shouldNot]);
useEffect(getData, []); useEffect(getData, [getData])
return { entities, isLoading, isError, reload: getData }; return { entities, isLoading, isError, reload: getData };
} }

42
src/hooks/useStats.tsx Normal file
View File

@@ -0,0 +1,42 @@
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
export default function useStats<T extends any>(
id?: string,
shouldNotQuery: boolean = !id,
queryType: string = "stats"
) {
type ElementType = T extends (infer U)[] ? U : never;
const [data, setData] = useState<T>({} as unknown as T);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
if (shouldNotQuery) return;
setIsLoading(true);
setIsError(false);
let endpoint = `/api/stats/user/${id}`;
if (queryType) endpoint += `?query=${queryType}`;
axios
.get<T>(endpoint)
.then((response) => {
console.log(response.data);
setData(response.data);
})
.catch(() => setIsError(true))
.finally(() => setIsLoading(false));
}, [id, shouldNotQuery, queryType]);
useEffect(() => {
getData();
}, [getData]);
return {
data,
reload: getData,
isLoading,
isError,
};
}

View File

@@ -1,26 +1,28 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import axios from "axios"; import axios from "axios";
const useTicketsListener = (userId?: string) => { const useTicketsListener = (userId?: string, canFetch?: boolean) => {
const [assignedTickets, setAssignedTickets] = useState([]); const [assignedTickets, setAssignedTickets] = useState([]);
const getData = () => { const getData = useCallback(() => {
axios axios
.get("/api/tickets/assignedToUser") .get("/api/tickets/assignedToUser")
.then((response) => setAssignedTickets(response.data)); .then((response) => setAssignedTickets(response.data));
};
useEffect(() => {
getData();
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!canFetch) return;
getData();
}, [canFetch, getData]);
useEffect(() => {
if (!canFetch) return;
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
getData(); getData();
}, 60 * 1000); }, 60 * 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [assignedTickets]); }, [assignedTickets, canFetch, getData]);
if (userId) { if (userId) {
return { return {

View File

@@ -37,3 +37,5 @@ export interface Assignment {
} }
export type AssignmentWithCorporateId = Assignment & { corporateId: string }; export type AssignmentWithCorporateId = Assignment & { corporateId: string };
export type AssignmentWithHasResults = Assignment & { hasResults: boolean };

View File

@@ -1,9 +1,9 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import Layout from "@/components/High/Layout"; import { LayoutContext } from "@/components/High/Layout";
import Finish from "@/exams/Finish"; import Finish from "@/exams/Finish";
import Level from "@/exams/Level"; import Level from "@/exams/Level";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
@@ -11,9 +11,12 @@ import Reading from "@/exams/Reading";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import { Exam, LevelExam, UserSolution, Variant, WritingExam } from "@/interfaces/exam"; import { Exam, LevelExam, Variant } from "@/interfaces/exam";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; import {
evaluateSpeakingAnswer,
evaluateWritingAnswer,
} from "@/utils/evaluation";
import { getExam } from "@/utils/exams"; import { getExam } from "@/utils/exams";
import axios from "axios"; import axios from "axios";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -26,11 +29,16 @@ import useEvaluationPolling from "@/hooks/useEvaluationPolling";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
user: User; user: User;
destination?: string destination?: string;
hideSidebar?: boolean hideSidebar?: boolean;
} }
export default function ExamPage({ page, user, destination = "/", hideSidebar = false }: Props) { export default function ExamPage({
page,
user,
destination = "/",
hideSidebar = false,
}: Props) {
const router = useRouter(); const router = useRouter();
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
@@ -38,14 +46,22 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
const [moduleLock, setModuleLock] = useState(false); const [moduleLock, setModuleLock] = useState(false);
const { const {
exam, setExam, exam,
setExam,
exams, exams,
sessionId, setSessionId, setPartIndex, sessionId,
moduleIndex, setModuleIndex, setSessionId,
setQuestionIndex, setExerciseIndex, setPartIndex,
userSolutions, setUserSolutions, moduleIndex,
showSolutions, setShowSolutions, setModuleIndex,
selectedModules, setSelectedModules, setQuestionIndex,
setExerciseIndex,
userSolutions,
setUserSolutions,
showSolutions,
setShowSolutions,
selectedModules,
setSelectedModules,
setUser, setUser,
inactivity, inactivity,
timeSpent, timeSpent,
@@ -61,7 +77,9 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
} = useExamStore(); } = useExamStore();
const [isFetchingExams, setIsFetchingExams] = useState(false); const [isFetchingExams, setIsFetchingExams] = useState(false);
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length); const [isExamLoaded, setIsExamLoaded] = useState(
moduleIndex < selectedModules.length
);
useEffect(() => { useEffect(() => {
setIsExamLoaded(moduleIndex < selectedModules.length); setIsExamLoaded(moduleIndex < selectedModules.length);
@@ -88,13 +106,21 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
module, module,
avoidRepeated, avoidRepeated,
variant, variant,
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined, user?.type === "student" || user?.type === "developer"
), ? user.preferredGender
: undefined
)
); );
Promise.all(examPromises).then((values) => { Promise.all(examPromises).then((values) => {
setIsFetchingExams(false); setIsFetchingExams(false);
if (values.every((x) => !!x)) { if (values.every((x) => !!x)) {
dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } }) dispatch({
type: "INIT_EXAM",
payload: {
exams: values.map((x) => x!),
modules: selectedModules,
},
});
} else { } else {
toast.error("Something went wrong, please try again"); toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500); setTimeout(router.reload, 500);
@@ -105,7 +131,6 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, exams]); }, [selectedModules, exams]);
const reset = () => { const reset = () => {
resetStore(); resetStore();
setVariant("full"); setVariant("full");
@@ -117,45 +142,55 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
useEffect(() => { useEffect(() => {
setModuleLock(true); setModuleLock(true);
}, [flags.finalizeModule]) }, [flags.finalizeModule]);
useEffect(() => { useEffect(() => {
if (flags.finalizeModule && !showSolutions) { if (flags.finalizeModule && !showSolutions) {
if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0) { if (
exam &&
(exam.module === "writing" || exam.module === "speaking") &&
userSolutions.length > 0
) {
(async () => { (async () => {
try { try {
const results = await Promise.all( const results = await Promise.all(
exam.exercises.map(async (exercise, index) => { exam.exercises.map(async (exercise, index) => {
if (exercise.type === "writing") { if (exercise.type === "writing") {
const sol = await evaluateWritingAnswer( const sol = await evaluateWritingAnswer(
user.id, sessionId, exercise, index + 1, user.id,
sessionId,
exercise,
index + 1,
userSolutions.find((x) => x.exercise === exercise.id)!, userSolutions.find((x) => x.exercise === exercise.id)!,
exercise.attachment?.url exercise.attachment?.url
); );
return sol; return sol;
} }
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") { if (
exercise.type === "interactiveSpeaking" ||
exercise.type === "speaking"
) {
const sol = await evaluateSpeakingAnswer( const sol = await evaluateSpeakingAnswer(
user.id, user.id,
sessionId, sessionId,
exercise, exercise,
userSolutions.find((x) => x.exercise === exercise.id)!, userSolutions.find((x) => x.exercise === exercise.id)!,
index + 1, index + 1
); );
return sol; return sol;
} }
return null; return null;
}) })
); );
const updatedSolutions = userSolutions.map(solution => { const updatedSolutions = userSolutions.map((solution) => {
const completed = results.filter(r => r !== null).find( const completed = results
(c: any) => c.exercise === solution.exercise .filter((r) => r !== null)
); .find((c: any) => c.exercise === solution.exercise);
return completed || solution; return completed || solution;
}); });
setUserSolutions(updatedSolutions); setUserSolutions(updatedSolutions);
} catch (error) { } catch (error) {
console.error('Error during module evaluation:', error); console.error("Error during module evaluation:", error);
} finally { } finally {
setModuleLock(false); setModuleLock(false);
} }
@@ -164,7 +199,15 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
setModuleLock(false); setModuleLock(false);
} }
} }
}, [exam, showSolutions, userSolutions, sessionId, user.id, flags.finalizeModule, setUserSolutions]); }, [
exam,
showSolutions,
userSolutions,
sessionId,
user.id,
flags.finalizeModule,
setUserSolutions,
]);
useEffect(() => { useEffect(() => {
if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) { if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) {
@@ -172,12 +215,24 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
setModuleIndex(-1); setModuleIndex(-1);
await saveStats(); await saveStats();
await axios.get("/api/stats/update"); await axios.get("/api/stats/update");
})() })();
} }
}, [flags.finalizeExam, moduleIndex, saveStats, setModuleIndex, userSolutions, moduleLock, flags.finalizeModule]); }, [
flags.finalizeExam,
moduleIndex,
saveStats,
setModuleIndex,
userSolutions,
moduleLock,
flags.finalizeModule,
]);
useEffect(() => { useEffect(() => {
if (flags.finalizeExam && !userSolutions.some(s => s.isDisabled) && !moduleLock) { if (
flags.finalizeExam &&
!userSolutions.some((s) => s.isDisabled) &&
!moduleLock
) {
setShowSolutions(true); setShowSolutions(true);
setFlags({ finalizeExam: false }); setFlags({ finalizeExam: false });
dispatch({ type: "UPDATE_EXAMS" }); dispatch({ type: "UPDATE_EXAMS" });
@@ -185,8 +240,9 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]); }, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]);
const aggregateScoresByModule = (
const aggregateScoresByModule = (isPractice?: boolean): { isPractice?: boolean
): {
module: Module; module: Module;
total: number; total: number;
missing: number; missing: number;
@@ -222,29 +278,40 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
}, },
}; };
userSolutions.filter(x => isPractice ? x.isPractice : !x.isPractice).forEach((x) => { userSolutions.forEach((x) => {
if (x.isPractice === isPractice) {
const examModule = const examModule =
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined); x.module ||
(x.type === "writing"
? "writing"
: x.type === "speaking" || x.type === "interactiveSpeaking"
? "speaking"
: undefined);
scores[examModule!] = { scores[examModule!] = {
total: scores[examModule!].total + x.score.total, total: scores[examModule!].total + x.score.total,
correct: scores[examModule!].correct + x.score.correct, correct: scores[examModule!].correct + x.score.correct,
missing: scores[examModule!].missing + x.score.missing, missing: scores[examModule!].missing + x.score.missing,
}; };
}
}); });
return Object.keys(scores) return Object.keys(scores).reduce<
.filter((x) => scores[x as Module].total > 0) { module: Module; total: number; missing: number; correct: number }[]
.map((x) => ({ module: x as Module, ...scores[x as Module] })); >((accm, x) => {
if (scores[x as Module].total > 0)
accm.push({ module: x as Module, ...scores[x as Module] });
return accm;
}, []);
}; };
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = { const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
"reading": Reading as React.ComponentType<ExamProps<Exam>>, reading: Reading as React.ComponentType<ExamProps<Exam>>,
"listening": Listening as React.ComponentType<ExamProps<Exam>>, listening: Listening as React.ComponentType<ExamProps<Exam>>,
"writing": Writing as React.ComponentType<ExamProps<Exam>>, writing: Writing as React.ComponentType<ExamProps<Exam>>,
"speaking": Speaking as React.ComponentType<ExamProps<Exam>>, speaking: Speaking as React.ComponentType<ExamProps<Exam>>,
"level": Level as React.ComponentType<ExamProps<Exam>>, level: Level as React.ComponentType<ExamProps<Exam>>,
} };
const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined; const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
@@ -253,38 +320,73 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
reset(); reset();
}; };
const {
setBgColor,
setHideSidebar,
setFocusMode,
setOnFocusLayerMouseEnter,
} = React.useContext(LayoutContext);
useEffect(() => {
setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
}, []);
useEffect(() => {
setBgColor(bgColor);
setHideSidebar(hideSidebar);
setFocusMode(
selectedModules.length !== 0 &&
!showSolutions &&
moduleIndex < selectedModules.length
);
}, [
bgColor,
hideSidebar,
moduleIndex,
selectedModules.length,
setBgColor,
setFocusMode,
setHideSidebar,
showSolutions,
]);
return ( return (
<> <>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout
user={user}
bgColor={bgColor}
hideSidebar={hideSidebar}
className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
<> <>
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
{selectedModules.length === 0 && <Selection {selectedModules.length === 0 && (
<Selection
page={page} page={page}
user={user!} user={user!}
onStart={(modules: Module[], avoid: boolean, variant: Variant) => { onStart={(
modules: Module[],
avoid: boolean,
variant: Variant
) => {
setModuleIndex(0); setModuleIndex(0);
setAvoidRepeated(avoid); setAvoidRepeated(avoid);
setSelectedModules(modules); setSelectedModules(modules);
setVariant(variant); setVariant(variant);
}} }}
/>} />
)}
{isFetchingExams && ( {isFetchingExams && (
<div className="flex flex-grow flex-col items-center justify-center animate-pulse"> <div className="flex flex-grow flex-col items-center justify-center animate-pulse">
<span className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`} /> <span
<span className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}>Loading Exam ...</span> className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`}
/>
<span
className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
>
Loading Exam ...
</span>
</div> </div>
)} )}
{(moduleIndex === -1 && selectedModules.length !== 0) && {moduleIndex === -1 && selectedModules.length !== 0 && (
<Finish <Finish
isLoading={userSolutions.some(s => s.isDisabled)} isLoading={userSolutions.some((s) => s.isDisabled)}
user={user!} user={user!}
modules={selectedModules} modules={selectedModules}
solutions={userSolutions} solutions={userSolutions}
@@ -297,11 +399,19 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
onViewResults={(index?: number) => { onViewResults={(index?: number) => {
if (exams[0].module === "level") { if (exams[0].module === "level") {
const levelExam = exams[0] as LevelExam; const levelExam = exams[0] as LevelExam;
const allExercises = levelExam.parts.flatMap((part) => part.exercises); const allExercises = levelExam.parts.flatMap(
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index])); (part) => part.exercises
const orderedSolutions = userSolutions.slice().sort((a, b) => { );
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity; const exerciseOrderMap = new Map(
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity; allExercises.map((ex, index) => [ex.id, index])
);
const orderedSolutions = userSolutions
.slice()
.sort((a, b) => {
const indexA =
exerciseOrderMap.get(a.exercise) ?? Infinity;
const indexB =
exerciseOrderMap.get(b.exercise) ?? Infinity;
return indexA - indexB; return indexA - indexB;
}); });
setUserSolutions(orderedSolutions); setUserSolutions(orderedSolutions);
@@ -324,12 +434,16 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
}} }}
scores={aggregateScoresByModule()} scores={aggregateScoresByModule()}
practiceScores={aggregateScoresByModule(true)} practiceScores={aggregateScoresByModule(true)}
/>} />
)}
{/* Exam is on going, display it and the abandon modal */} {/* Exam is on going, display it and the abandon modal */}
{isExamLoaded && moduleIndex !== -1 && ( {isExamLoaded && moduleIndex !== -1 && (
<> <>
{exam && CurrentExam && <CurrentExam exam={exam} showSolutions={showSolutions} />} {exam && CurrentExam && (
{!showSolutions && <AbandonPopup <CurrentExam exam={exam} showSolutions={showSolutions} />
)}
{!showSolutions && (
<AbandonPopup
isOpen={showAbandonPopup} isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise" abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard." abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
@@ -337,11 +451,10 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
onAbandon={onAbandon} onAbandon={onAbandon}
onCancel={() => setShowAbandonPopup(false)} onCancel={() => setShowAbandonPopup(false)}
/> />
} )}
</> </>
)} )}
</> </>
</Layout>
)} )}
</> </>
); );

View File

@@ -1,18 +1,14 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import useGroups from "@/hooks/useGroups";
import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize, sortBy } from "lodash"; import { capitalize } from "lodash";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import { BsArrowRepeat } from "react-icons/bs"; import { BsArrowRepeat } from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment"; import PaymobPayment from "@/components/PaymobPayment";
import moment from "moment"; import moment from "moment";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
@@ -22,44 +18,65 @@ import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
interface Props { interface Props {
user: User user: User;
discounts: Discount[] discounts: Discount[];
packages: Package[] packages: Package[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
hasExpired?: boolean; hasExpired?: boolean;
reload: () => void; reload: () => void;
} }
export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) { export default function PaymentDue({
user,
discounts = [],
entities = [],
packages = [],
hasExpired = false,
reload,
}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [entity, setEntity] = useState<EntityWithRoles>() const [entity, setEntity] = useState<EntityWithRoles>();
const router = useRouter(); const router = useRouter();
const { users } = useUsers(); const { users } = useUsers();
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); const {
invites,
isLoading: isInvitesLoading,
reload: reloadInvites,
} = useInvites({ to: user?.id });
const isIndividual = useMemo(() => { const isIndividual = useMemo(() => {
if (isAdmin(user)) return false; if (isAdmin(user)) return false;
if (user?.type !== "student") return false; if (user?.type !== "student") return false;
return user.entities.length === 0 return user.entities.length === 0;
}, [user]) }, [user]);
const appliedDiscount = useMemo(() => { const appliedDiscount = useMemo(() => {
const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift(); const biggestDiscount = [...discounts]
.sort((a, b) => b.percentage - a.percentage)
.shift();
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) if (
!biggestDiscount ||
(biggestDiscount.validUntil &&
moment(biggestDiscount.validUntil).isBefore(moment()))
)
return 0; return 0;
return biggestDiscount.percentage return biggestDiscount.percentage;
}, [discounts]) }, [discounts]);
const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity') const entitiesThatCanBePaid = useAllowedEntities(
user,
entities,
"pay_entity"
);
useEffect(() => { useEffect(() => {
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]) if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]);
}, [entitiesThatCanBePaid]) }, [entitiesThatCanBePaid]);
return ( return (
<> <>
@@ -67,26 +84,42 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
{isLoading && ( {isLoading && (
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60"> <div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-8 text-white"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-8 text-white">
<span className={clsx("loading loading-infinity w-48 animate-pulse")} /> <span
<span className={clsx("text-2xl font-bold animate-pulse")}>Completing your payment...</span> className={clsx("loading loading-infinity w-48 animate-pulse")}
<span>If you canceled your payment or it failed, please click the button below to restart</span> />
<span className={clsx("text-2xl font-bold animate-pulse")}>
Completing your payment...
</span>
<span>
If you canceled your payment or it failed, please click the button
below to restart
</span>
<button <button
onClick={() => setIsLoading(false)} onClick={() => setIsLoading(false)}
className="border border-white rounded-full px-4 py-2 hover:bg-white/80 hover:text-black cursor-pointer transition ease-in-out duration-300"> className="border border-white rounded-full px-4 py-2 hover:bg-white/80 hover:text-black cursor-pointer transition ease-in-out duration-300"
>
Cancel Payment Cancel Payment
</button> </button>
</div> </div>
</div> </div>
)} )}
<Layout user={user} navDisabled={hasExpired}> <>
{invites.length > 0 && ( {invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div
onClick={reloadInvites} onClick={reloadInvites}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<span className="text-mti-black text-lg font-bold">Invites</span> >
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} /> <span className="text-mti-black text-lg font-bold">
Invites
</span>
<BsArrowRepeat
className={clsx(
"text-xl",
isInvitesLoading && "animate-spin"
)}
/>
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
@@ -106,21 +139,40 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
)} )}
<div className="flex w-full flex-col items-center justify-center gap-4 text-center"> <div className="flex w-full flex-col items-center justify-center gap-4 text-center">
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>} {hasExpired && (
<span className="text-lg font-bold">
You do not have time credits for your account type!
</span>
)}
{isIndividual && ( {isIndividual && (
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll"> <div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below: To add to your use of EnCoach, please purchase one of the time
packages available below:
</span> </span>
<div className="flex w-full flex-wrap justify-center gap-8"> <div className="flex w-full flex-wrap justify-center gap-8">
{packages.map((p) => ( {packages.map((p) => (
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> <div
key={p.id}
className={clsx(
"flex flex-col items-start gap-6 rounded-xl bg-white p-4"
)}
>
<div className="mb-2 flex flex-col items-start"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <img
src="/logo_title.png"
alt="EnCoach's Logo"
className="w-32"
/>
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
EnCoach - {p.duration}{" "} EnCoach - {p.duration}{" "}
{capitalize( {capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, p.duration === 1
? p.duration_unit.slice(
0,
p.duration_unit.length - 1
)
: p.duration_unit
)} )}
</span> </span>
</div> </div>
@@ -136,7 +188,11 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
{p.price} {p.currency} {p.price} {p.currency}
</span> </span>
<span className="text-2xl text-mti-red-light"> <span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} {(
p.price -
p.price * (appliedDiscount / 100)
).toFixed(2)}{" "}
{p.currency}
</span> </span>
</div> </div>
)} )}
@@ -149,15 +205,24 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
currency={p.currency} currency={p.currency}
duration={p.duration} duration={p.duration}
duration_unit={p.duration_unit} duration_unit={p.duration_unit}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} price={
+(
p.price -
p.price * (appliedDiscount / 100)
).toFixed(2)
}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li> <li>- Train your abilities for the IELTS exam</li>
<li>- Gain insights into your weaknesses and strengths</li> <li>
<li>- Allow yourself to correctly prepare for the exam</li> - Gain insights into your weaknesses and strengths
</li>
<li>
- Allow yourself to correctly prepare for the exam
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -166,26 +231,43 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
</div> </div>
)} )}
{!isIndividual && entitiesThatCanBePaid.length > 0 && {!isIndividual &&
entitiesThatCanBePaid.length > 0 &&
entity?.payment && ( entity?.payment && (
<div className="flex flex-col items-center gap-8"> <div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}> <div
<label className="font-normal text-base text-mti-gray-dim">Entity</label> className={clsx("flex flex-col items-center gap-4 w-full")}
>
<label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select <Select
defaultValue={{ value: entity?.id, label: entity?.label }} defaultValue={{ value: entity?.id, label: entity?.label }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))} options={entitiesThatCanBePaid.map((e) => ({
onChange={(e) => e?.value ? setEntity(e?.entity) : null} value: e.id,
label: e.label,
entity: e,
}))}
onChange={(e) => (e?.value ? setEntity(e?.entity) : null)}
className="!w-full max-w-[400px] self-center" className="!w-full max-w-[400px] self-center"
/> />
</div> </div>
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach and that of your students and teachers, please pay your designated package To add to your use of EnCoach and that of your students and
below: teachers, please pay your designated package below:
</span> </span>
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> <div
className={clsx(
"flex flex-col items-start gap-6 rounded-xl bg-white p-4"
)}
>
<div className="mb-2 flex flex-col items-start"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <img
src="/logo_title.png"
alt="EnCoach's Logo"
className="w-32"
/>
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
EnCoach - {12} Months EnCoach - {12} Months
</span> </span>
@@ -212,10 +294,14 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li> <li>
- Allow a total of {entity.licenses} students and teachers to use EnCoach - Allow a total of {entity.licenses} students and
teachers to use EnCoach
</li> </li>
<li>- Train their abilities for the IELTS exam</li> <li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li> <li>
- Gain insights into your students&apos; weaknesses and
strengths
</li>
<li>- Allow them to correctly prepare for the exam</li> <li>- Allow them to correctly prepare for the exam</li>
</ul> </ul>
</div> </div>
@@ -225,11 +311,12 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
{!isIndividual && entitiesThatCanBePaid.length === 0 && ( {!isIndividual && entitiesThatCanBePaid.length === 0 && (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="max-w-lg"> <span className="max-w-lg">
You are not the person in charge of your time credits, please contact your administrator about this situation. You are not the person in charge of your time credits, please
contact your administrator about this situation.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your If you believe this to be a mistake, please contact the
patience. platform&apos;s administration, thank you for your patience.
</span> </span>
</div> </div>
)} )}
@@ -237,26 +324,39 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
entitiesThatCanBePaid.length > 0 && entitiesThatCanBePaid.length > 0 &&
!entity?.payment && ( !entity?.payment && (
<div className="flex flex-col items-center gap-8"> <div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}> <div
<label className="font-normal text-base text-mti-gray-dim">Entity</label> className={clsx("flex flex-col items-center gap-4 w-full")}
>
<label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select <Select
defaultValue={{ value: entity?.id || "", label: entity?.label || "" }} defaultValue={{
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))} value: entity?.id || "",
onChange={(e) => e?.value ? setEntity(e?.entity) : null} label: entity?.label || "",
}}
options={entitiesThatCanBePaid.map((e) => ({
value: e.id,
label: e.label,
entity: e,
}))}
onChange={(e) => (e?.value ? setEntity(e?.entity) : null)}
className="!w-full max-w-[400px] self-center" className="!w-full max-w-[400px] self-center"
/> />
</div> </div>
<span className="max-w-lg"> <span className="max-w-lg">
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users An admin nor your agent have yet set the price intended to
you desire and your expected monthly duration. your requirements in terms of the amount of users you desire
and your expected monthly duration.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
Please try again later or contact your agent or an admin, thank you for your patience. Please try again later or contact your agent or an admin,
thank you for your patience.
</span> </span>
</div> </div>
)} )}
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -6,19 +6,47 @@ import "primereact/resources/themes/lara-light-indigo/theme.css";
import "primereact/resources/primereact.min.css"; import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css"; import "primeicons/primeicons.css";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
import {useRouter} from "next/router"; import { Router, useRouter } from "next/router";
import {useEffect} from "react"; import { useEffect, useState } from "react";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import Layout from "../components/High/Layout";
import useEntities from "../hooks/useEntities";
import UserProfileSkeleton from "../components/Medium/UserProfileSkeleton";
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
const [loading, setLoading] = useState(false);
const { reset } = useExamStore(); const { reset } = useExamStore();
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
const setIsSidebarMinimized = usePreferencesStore(
(state) => state.setSidebarMinimized
);
const router = useRouter(); const router = useRouter();
const { entities } = useEntities(!pageProps?.user?.id);
useEffect(() => { useEffect(() => {
if (router.pathname !== "/exam" && router.pathname !== "/exercises") reset(); const start = () => {
setLoading(true);
};
const end = () => {
setLoading(false);
};
Router.events.on("routeChangeStart", start);
Router.events.on("routeChangeComplete", end);
Router.events.on("routeChangeError", end);
return () => {
Router.events.off("routeChangeStart", start);
Router.events.off("routeChangeComplete", end);
Router.events.off("routeChangeError", end);
};
}, []);
useEffect(() => {
if (router.pathname !== "/exam" && router.pathname !== "/exercises")
reset();
}, [router.pathname, reset]); }, [router.pathname, reset]);
useEffect(() => { useEffect(() => {
@@ -31,5 +59,13 @@ export default function App({Component, pageProps}: AppProps) {
} }
}, [setIsSidebarMinimized]); }, [setIsSidebarMinimized]);
return <Component {...pageProps} />; return (
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
{loading ? (
<UserProfileSkeleton />
) : (
<Component {...pageProps} entities={entities} />
)}
</Layout>
);
} }

View File

@@ -1,10 +1,9 @@
// 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 type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { getDetailedStatsByUser } from "../../../../utils/stats.be";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -14,11 +13,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const {user} = req.query; const { user, query } = req.query as { user: string, query?: string };
const snapshot = await db.collection("stats").aggregate([
{ $match: { user: user } },
{ $sort: { "date": 1 } }
]).toArray();
const snapshot = await getDetailedStatsByUser(user, query);
res.status(200).json(snapshot); res.status(200).json(snapshot);
} }

View File

@@ -1,15 +1,12 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers";
import { Grading, Module } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { getExamById } from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { calculateBandScore, getGradingLabel } from "@/utils/score"; import { calculateBandScore, getGradingLabel } from "@/utils/score";
import { convertToUserSolutions } from "@/utils/stats";
import { getUserName } from "@/utils/users"; import { getUserName } from "@/utils/users";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
@@ -23,13 +20,11 @@ import { withIronSessionSsr } from "iron-session/next";
import { checkAccess, doesEntityAllow } from "@/utils/permissions"; import { checkAccess, doesEntityAllow } from "@/utils/permissions";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getAssignment } from "@/utils/assignments.be"; import { getAssignment } from "@/utils/assignments.be";
import { getEntitiesUsers, getEntityUsers, getUsers } from "@/utils/users.be"; import { getEntityUsers, getUsers } from "@/utils/users.be";
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be"; import { getEntityWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities, getGroupsByEntity } from "@/utils/groups.be";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Head from "next/head"; import Head from "next/head";
import Layout from "@/components/High/Layout";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import Link from "next/link"; import Link from "next/link";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
@@ -353,7 +348,7 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between"> <div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -466,7 +461,7 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
</Button> </Button>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
@@ -212,7 +211,7 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
@@ -589,7 +588,7 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
</Button> </Button>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
@@ -170,7 +169,7 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
@@ -528,7 +527,7 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
</Button> </Button>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,15 +1,12 @@
import Layout from "@/components/High/Layout";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import AssignmentCard from "@/components/AssignmentCard"; import AssignmentCard from "@/components/AssignmentCard";
import AssignmentView from "@/components/AssignmentView";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { CorporateUser, Group, User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { getUserCompanyName } from "@/resources/user";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import {
@@ -21,15 +18,13 @@ import {
} from "@/utils/assignments"; } from "@/utils/assignments";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { groupBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { BsChevronLeft, BsPlus } from "react-icons/bs"; import { BsChevronLeft, BsPlus } from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
@@ -98,7 +93,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
@@ -223,7 +218,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
))} ))}
</div> </div>
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
@@ -183,7 +182,7 @@ export default function Home({ user, group, users, entity }: Props) {
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -339,7 +338,7 @@ export default function Home({ user, group, users, entity }: Props) {
</button> </button>
))} ))}
</section> </section>
</Layout> </>
)} )}
</> </>
); );

View File

@@ -1,19 +1,18 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch"; import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles} from "@/interfaces/entity"; import {EntityWithRoles} from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import {filterBy, mapBy, redirect, serialize} from "@/utils"; import {filterBy, mapBy, redirect, serialize} from "@/utils";
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; import { getEntitiesWithRoles} from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName, isAdmin} from "@/utils/users"; import {getUserName, isAdmin} from "@/utils/users";
import {getEntitiesUsers, getLinkedUsers} from "@/utils/users.be"; import {getEntitiesUsers} from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
@@ -102,7 +101,7 @@ export default function Home({user, users, entities}: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between"> <div className="flex gap-3 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -217,7 +216,7 @@ export default function Home({user, users, entities}: Props) {
</button> </button>
))} ))}
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -3,7 +3,6 @@ import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { GroupWithUsers, User } from "@/interfaces/user"; import { GroupWithUsers, User } from "@/interfaces/user";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName, isAdmin } from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
@@ -11,13 +10,13 @@ import { convertToUsers, getGroupsForEntities } from "@/utils/groups.be";
import { getSpecificUsers } from "@/utils/users.be"; import { getSpecificUsers } from "@/utils/users.be";
import Link from "next/link"; import Link from "next/link";
import { uniq } from "lodash"; import { uniq } from "lodash";
import { BsFillMortarboardFill, BsPlus } from "react-icons/bs"; import { BsPlus } from "react-icons/bs";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { FaPersonChalkboard } from "react-icons/fa6"; import { FaPersonChalkboard } from "react-icons/fa6";
@@ -122,7 +121,7 @@ export default function Home({ user, groups, entities }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="!gap-4"> <>
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]"> <Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]">
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} /> <StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
@@ -153,7 +152,7 @@ export default function Home({ user, groups, entities }: Props) {
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard} firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,29 +1,28 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Stat, Type, User } from "@/interfaces/user";
import { Group, Stat, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be"; import {
import { getEntitiesWithRoles } from "@/utils/entities.be"; countEntitiesAssignments,
import { countGroups, getGroups } from "@/utils/groups.be"; } from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { countUsers, getUser, getUsers } from "@/utils/users.be"; import {
countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data,
BsEnvelopePaper, BsEnvelopePaper,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
@@ -36,39 +35,78 @@ import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
students: User[]; students: User[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
usersCount: { [key in Type]: number } usersCount: { [key in Type]: number };
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
groupsCount: number; groupsCount: number;
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/") if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const students = await getUsers({ type: 'student' }); const students = await getUsers(
const usersCount = { { type: "student" },
student: await countUsers({ type: "student" }), 10,
teacher: await countUsers({ type: "teacher" }), {
corporate: await countUsers({ type: "corporate" }), averageLevel: -1,
mastercorporate: await countUsers({ type: "mastercorporate" }), },
} { id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 }) const usersCount = await countUsersByTypes([
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 }) "student",
"teacher",
"corporate",
"mastercorporate",
]);
const latestStudents = await getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"),
{ archived: { $ne: true } }
);
const entities = await getEntitiesWithRoles();
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } });
const groupsCount = await countGroups(); const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, 'id')); const stats = await getStatsByUsers(mapBy(students, "id"));
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) }; return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
stats,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ export default function Dashboard({
@@ -80,7 +118,7 @@ export default function Dashboard({
entities, entities,
assignmentsCount, assignmentsCount,
stats, stats,
groupsCount groupsCount,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
@@ -96,7 +134,7 @@ export default function Dashboard({
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
@@ -133,19 +171,22 @@ export default function Dashboard({
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={usersCount.student} value={usersCount.student}
@@ -161,31 +202,19 @@ export default function Dashboard({
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,28 +1,29 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, StudentUser, Type, User } from "@/interfaces/user"; import { Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments } from "@/utils/assignments.be"; import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities } from "@/utils/groups.be"; import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import {
import { calculateAverageLevel } from "@/utils/score"; checkAccess,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import {
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be"; countAllowedUsers,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
BsClipboard2Data,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsPencilSquare, BsPencilSquare,
@@ -37,10 +38,10 @@ import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
students: StudentUser[] students: StudentUser[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
userCounts: { [key in Type]: number } userCounts: { [key in Type]: number };
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
@@ -48,38 +49,113 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/") if (!checkAccess(user, ["admin", "developer", "corporate"]))
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDS
);
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students") const {
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers") ["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
]);
const students = const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 }); const entitiesIDS = mapBy(entities, "id") || [];
const latestStudents =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 })
const latestTeachers =
await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 })
const userCounts = await countAllowedUsers(user, entities)
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) }; const students = await getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ averageLevel: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestStudents = await getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{
type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const userCounts = await countAllowedUsers(user, entities);
const assignmentsCount = await countEntitiesAssignments(
entitiesIDS,
{ archived: { $ne: true } }
);
const groupsCount = await countGroupsByEntities(entitiesIDS);
return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) { export default function Dashboard({
const totalCount = useMemo(() => user,
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) students,
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
stats = [],
groupsCount,
}: Props) {
const totalCount = useMemo(
() =>
userCounts.corporate +
userCounts.mastercorporate +
userCounts.student +
userCounts.teacher,
[userCounts]
);
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const totalLicenses = useMemo(
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') () =>
entities.reduce(
(acc, curr) => acc + parseInt(curr.licenses.toString()),
0
),
[entities]
);
const allowedEntityStatistics = useAllowedEntities(
user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
const router = useRouter(); const router = useRouter();
@@ -95,7 +171,7 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
{entities.length > 0 && ( {entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1"> <div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
@@ -124,14 +200,16 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`} value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple" color="purple"
/> />
{allowedEntityStatistics.length > 0 && ( {allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={allowedEntityStatistics.length} value={allowedEntityStatistics.length}
@@ -139,7 +217,8 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
/> />
)} )}
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={userCounts.student} value={userCounts.student}
@@ -149,7 +228,11 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose" color="rose"
/> />
<IconCard <IconCard
@@ -164,31 +247,19 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
</div> </div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,29 +1,27 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Stat, Type, User } from "@/interfaces/user";
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be"; import {
import { getEntitiesWithRoles } from "@/utils/entities.be"; countEntitiesAssignments,
import { countGroups, getGroups } from "@/utils/groups.be"; } from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import {
import { countUsers, getUser, getUsers } from "@/utils/users.be"; countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data,
BsEnvelopePaper, BsEnvelopePaper,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
@@ -36,37 +34,73 @@ import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
students: User[]; students: User[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
usersCount: { [key in Type]: number } usersCount: { [key in Type]: number };
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
groupsCount: number; groupsCount: number;
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/") if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const students = await getUsers({ type: 'student' }, 10, { averageLevel: -1 }); const students = await getUsers(
const usersCount = { { type: "student" },
student: await countUsers({ type: "student" }), 10,
teacher: await countUsers({ type: "teacher" }), {
corporate: await countUsers({ type: "corporate" }), averageLevel: -1,
mastercorporate: await countUsers({ type: "mastercorporate" }), },
} { id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 }) const usersCount = await countUsersByTypes([
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 }) "student",
"teacher",
"corporate",
"mastercorporate",
]);
const entities = await getEntitiesWithRoles(); const latestStudents = await getUsers(
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } }); { type: "student" },
10,
{
registrationDate: -1,
},
{id:1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ id:1,name: 1, email: 1, profilePicture: 1 }
);
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"),
{ archived: { $ne: true } }
);
const groupsCount = await countGroups(); const groupsCount = await countGroups();
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, groupsCount }) }; return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ export default function Dashboard({
@@ -78,7 +112,7 @@ export default function Dashboard({
entities, entities,
assignmentsCount, assignmentsCount,
stats = [], stats = [],
groupsCount groupsCount,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
@@ -94,7 +128,7 @@ export default function Dashboard({
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
@@ -131,19 +165,22 @@ export default function Dashboard({
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={usersCount.student} value={usersCount.student}
@@ -159,31 +196,19 @@ export default function Dashboard({
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b.id))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a.id))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,38 +1,31 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Stat, StudentUser, Type, User } from "@/interfaces/user";
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities, getGroupsByEntities } from "@/utils/groups.be"; import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import {
import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; checkAccess,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { countAllowedUsers, getUsers } from "@/utils/users.be";
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsPaperclip,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
BsPeopleFill, BsPeopleFill,
@@ -44,10 +37,10 @@ import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
students: StudentUser[] students: StudentUser[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
userCounts: { [key in Type]: number } userCounts: { [key in Type]: number };
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
@@ -55,42 +48,115 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/") if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDS
);
const {
["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
]);
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students") const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
const students = const entitiesIDS = mapBy(entities, "id") || [];
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 });
const latestStudents =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 })
const latestTeachers =
await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 })
const userCounts = await countAllowedUsers(user, entities) const students = await getUsers(
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } }); { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
const groupsCount = await countGroupsByEntities(mapBy(entities, "id")); 10,
{ averageLevel: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) }; const latestStudents = await getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{
type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const userCounts = await countAllowedUsers(user, entities);
const assignmentsCount = await countEntitiesAssignments(entitiesIDS, {
archived: { $ne: true },
});
const groupsCount = await countGroupsByEntities(entitiesIDS);
return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) { export default function Dashboard({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
stats = [],
groupsCount,
}: Props) {
const totalCount = useMemo(
() =>
userCounts.corporate +
userCounts.mastercorporate +
userCounts.student +
userCounts.teacher,
[userCounts]
);
const totalCount = useMemo(() => const totalLicenses = useMemo(
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) () =>
entities.reduce(
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) (acc, curr) => acc + parseInt(curr.licenses.toString()),
0
),
[entities]
);
const router = useRouter(); const router = useRouter();
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const allowedEntityStatistics = useAllowedEntities(
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
return ( return (
<> <>
@@ -104,7 +170,7 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
@@ -134,14 +200,16 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`} value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple" color="purple"
/> />
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={userCounts.student} value={userCounts.student}
@@ -149,7 +217,8 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
/> />
)} )}
{allowedEntityStatistics.length > 0 && ( {allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={allowedEntityStatistics.length} value={allowedEntityStatistics.length}
@@ -161,43 +230,37 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
onClick={() => router.push("/assignments")} onClick={() => router.push("/assignments")}
label="Assignments" label="Assignments"
value={assignmentsCount} value={assignmentsCount}
className={clsx(allowedEntityStatistics.length === 0 && "col-span-2")} className={clsx(
allowedEntityStatistics.length === 0 && "col-span-2"
)}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose" color="rose"
/> />
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard"; import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
@@ -10,39 +9,48 @@ import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite"; import { InviteWithEntity } from "@/interfaces/invite";
import { Assignment } from "@/interfaces/results"; import { Assignment, AssignmentWithHasResults } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { activeAssignmentFilter } from "@/utils/assignments"; import { getAssignmentsForStudent } from "@/utils/assignments.be";
import { getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getEntities } from "@/utils/entities.be";
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { getGradingSystemByEntity } from "@/utils/grading.be"; import { getGradingSystemByEntity } from "@/utils/grading.be";
import { convertInvitersToEntity, getInvitesByInvitee } from "@/utils/invites.be"; import {
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils"; convertInvitersToEntity,
getInvitesByInvitee,
} from "@/utils/invites.be";
import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getGradingLabel } from "@/utils/score"; import { getGradingLabel } from "@/utils/score";
import { getSessionsByUser } from "@/utils/sessions.be"; import { getSessionsByUser } from "@/utils/sessions.be";
import { averageScore } from "@/utils/stats"; import { getDetailedStatsByUser } from "@/utils/stats.be";
import { getStatsByUser } from "@/utils/stats.be";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { capitalize, uniqBy } from "lodash"; import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import {
import { BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar } from "react-icons/bs"; BsBook,
BsClipboard,
BsFileEarmarkText,
BsHeadphones,
BsMegaphone,
BsPen,
BsPencil,
BsStar,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: AssignmentWithHasResults[];
stats: Stat[]; stats: { fullExams: number; uniqueModules: number; averageScore: number };
exams: Exam[]; exams: Exam[];
sessions: Session[]; sessions: Session[];
invites: InviteWithEntity[]; invites: InviteWithEntity[];
@@ -50,60 +58,93 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "student"])) if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/") return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS); const entities = await getEntities(entityIDS, { _id: 0, label: 1 });
const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } }); const currentDate = moment().toISOString();
const stats = await getStatsByUser(user.id); const assignments = await getAssignmentsForStudent(user.id, currentDate);
const sessions = await getSessionsByUser(user.id, 10); const stats = await getDetailedStatsByUser(user.id, "stats");
const assignmentsIDs = mapBy(assignments, "id");
const sessions = await getSessionsByUser(user.id, 10, {
["assignment.id"]: { $in: assignmentsIDs },
});
const invites = await getInvitesByInvitee(user.id); const invites = await getInvitesByInvitee(user.id);
const grading = await getGradingSystemByEntity(entityIDS[0] || ""); const grading = await getGradingSystemByEntity(entityIDS[0] || "", {
_id: 0,
const formattedInvites = await Promise.all(invites.map(convertInvitersToEntity)); steps: 1,
});
const formattedInvites = await Promise.all(
invites.map(convertInvitersToEntity)
);
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.flatMap((a) =>
a.exams.filter((e) => e.assignee === user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), a.exams.map((e: { module: string; id: string }) => ({
module: e.module,
id: e.id,
key: `${e.module}_${e.id}`,
}))
), ),
"key", "key"
); );
const exams = await getExamsByIds(examIDs); const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
return { props: serialize({ user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading }) }; return {
props: serialize({
user,
entities,
assignments,
stats,
exams,
sessions,
invites: formattedInvites,
grading,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, entities, assignments, stats, invites, grading, sessions, exams }: Props) { export default function Dashboard({
user,
entities,
assignments,
stats,
invites,
grading,
sessions,
exams,
}: Props) {
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const assignmentExams = exams.filter(e => { const assignmentExams = exams.filter((e) => {
const exam = findBy(assignment.exams, 'id', e.id) const exam = findBy(assignment.exams, "id", e.id);
return !!exam && exam.module === e.module return !!exam && exam.module === e.module;
}) });
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
payload: {
exams: assignmentExams.sort(sortByModule), exams: assignmentExams.sort(sortByModule),
modules: mapBy(assignmentExams.sort(sortByModule), 'module'), modules: mapBy(assignmentExams.sort(sortByModule), "module"),
assignment assignment,
} },
}) });
router.push("/exam"); router.push("/exam");
} }
}; };
const studentAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]);
return ( return (
<> <>
<Head> <Head>
@@ -116,9 +157,9 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> <div className="rounded-lg bg-neutral-200 px-2 py-1 ">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{mapBy(entities, "label")?.join(", ")}</b>
</div> </div>
)} )}
@@ -127,20 +168,27 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
user={user} user={user}
items={[ items={[
{ {
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: (
value: countFullExams(stats), <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: stats.fullExams,
label: "Exams", label: "Exams",
tooltip: "Number of all conducted completed 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" />, icon: (
value: countExamModules(stats), <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: stats.uniqueModules,
label: "Modules", label: "Modules",
tooltip: "Number of all exam modules performed including Level Test", 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" />, icon: (
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: `${stats?.averageScore.toFixed(2) || 0}%`,
label: "Average Score", label: "Average Score",
tooltip: "Average success rate for questions responded", tooltip: "Average success rate for questions responded",
}, },
@@ -151,40 +199,50 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-black text-lg font-bold">Assignments</span> <span className="text-mti-black text-lg font-bold">Assignments</span>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."} {assignments.length === 0 &&
{studentAssignments "Assignments will appear here. It seems that for now there are no assignments for you."}
.sort((a, b) => moment(a.startDate).diff(b.startDate)) {assignments.map((assignment) => (
.map((assignment) => (
<div <div
className={clsx( className={clsx(
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4", "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", assignment.hasResults && "border-mti-green-light"
)} )}
key={assignment.id}> key={assignment.id}
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3> <h3 className="text-mti-black/90 text-xl font-semibold">
{assignment.name}
</h3>
<span className="flex justify-between gap-1 text-lg"> <span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span> <span>
{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>-</span> <span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span> <span>
{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}
</span>
</span> </span>
</div> </div>
<div className="flex w-full items-center justify-between"> <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"> <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 {assignment.exams.map((e) => (
.filter((e) => e.assignee === user.id) <ModuleBadge
.map((e) => e.module) className="scale-110 w-full"
.sort(sortByModuleName) key={e.module}
.map((module) => ( module={e.module}
<ModuleBadge className="scale-110 w-full" key={module} module={module} /> />
))} ))}
</div> </div>
{!assignment.results.map((r) => r.user).includes(user.id) && ( {!assignment.hasResults && (
<> <>
<div <div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden" 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"> data-tip="Your screen size is too small to perform an assignment"
<Button className="h-full w-full !rounded-xl" variant="outline"> >
<Button
className="h-full w-full !rounded-xl"
variant="outline"
>
Start Start
</Button> </Button>
</div> </div>
@@ -192,24 +250,33 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
data-tip="You have already started this assignment!" data-tip="You have already started this assignment!"
className={clsx( className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer", "-md:hidden h-full w-full max-w-[50%] cursor-pointer",
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip", sessions.filter(
)}> (x) => x.assignment?.id === assignment.id
).length > 0 && "tooltip"
)}
>
<Button <Button
className={clsx("w-full h-full !rounded-xl")} className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)} onClick={() => startAssignment(assignment)}
variant="outline" variant="outline"
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}> disabled={
sessions.filter(
(x) => x.assignment?.id === assignment.id
).length > 0
}
>
Start Start
</Button> </Button>
</div> </div>
</> </>
)} )}
{assignment.results.map((r) => r.user).includes(user.id) && ( {assignment.hasResults && (
<Button <Button
onClick={() => router.push("/record")} onClick={() => router.push("/record")}
color="green" color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline"> variant="outline"
>
Submitted Submitted
</Button> </Button>
)} )}
@@ -224,7 +291,11 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => ( {invites.map((invite) => (
<InviteWithUserCard key={invite.id} invite={invite} reload={() => router.replace(router.asPath)} /> <InviteWithUserCard
key={invite.id}
invite={invite}
reload={() => router.replace(router.asPath)}
/>
))} ))}
</span> </span>
</section> </section>
@@ -238,20 +309,41 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
const desiredLevel = user.desiredLevels[module] || 9; const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0; const level = user.levels[module] || 0;
return ( return (
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}> <div
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 w-full"
key={module}
>
<div className="flex items-center gap-2 md:gap-3"> <div className="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"> <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 === "reading" && (
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />} <BsBook className="text-ielts-reading 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 === "listening" && (
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />} <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>
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span> <span className="text-sm font-bold md:font-extrabold">
{capitalize(module)}
</span>
<span className="text-mti-gray-dim text-sm font-normal"> <span className="text-mti-gray-dim text-sm font-normal">
{module === "level" && !!grading && `English Level: ${getGradingLabel(level, grading.steps)}`} {module === "level" &&
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`} !!grading &&
`English Level: ${getGradingLabel(
level,
grading.steps
)}`}
{module !== "level" &&
`Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
</span> </span>
</div> </div>
</div> </div>
@@ -259,9 +351,17 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<ProgressBar <ProgressBar
color={module} color={module}
label="" label=""
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)} mark={
module === "level"
? undefined
: Math.round((desiredLevel * 100) / 9)
}
markLabel={`Desired Level: ${desiredLevel}`} markLabel={`Desired Level: ${desiredLevel}`}
percentage={module === "level" ? level : Math.round((level * 100) / 9)} percentage={
module === "level"
? level
: Math.round((level * 100) / 9)
}
className="h-2 w-full" className="h-2 w-full"
/> />
</div> </div>
@@ -270,7 +370,7 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
})} })}
</div> </div>
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,8 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user"; import { Group, Stat, User } from "@/interfaces/user";
@@ -12,25 +10,27 @@ import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroupsByEntities } from "@/utils/groups.be"; import { getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import {
import { BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill, BsPersonFillGear } from "react-icons/bs"; BsEnvelopePaper,
BsPeople,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { filterAllowedUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
users: User[]; students: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: Assignment[];
stats: Stat[]; stats: Stat[];
@@ -38,29 +38,68 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "teacher"])) if (!checkAccess(user, ["admin", "developer", "teacher"]))
return redirect("/") return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const users = await filterAllowedUsers(user, entities) const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDS
);
const filteredEntities = findAllowedEntities(user, entities, "view_students");
const students = await getEntitiesUsers(
mapBy(filteredEntities, "id"),
{
type: "student",
},
0,
{
_id: 0,
id: 1,
name: 1,
email: 1,
profilePicture: 1,
levels: 1,
registrationDate: 1,
}
);
const assignments = await getEntitiesAssignments(entityIDS); const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id));
const stats = await getStatsByUsers(students.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS); const groups = await getGroupsByEntities(entityIDS);
return { props: serialize({ user, users, entities, assignments, stats, groups }) }; return {
props: serialize({ user, students, entities, assignments, stats, groups }),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { export default function Dashboard({
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); user,
students,
entities,
assignments,
stats,
groups,
}: Props) {
const router = useRouter(); const router = useRouter();
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const allowedEntityStatistics = useAllowedEntities(
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
return ( return (
<> <>
@@ -74,7 +113,7 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
{entities.length > 0 && ( {entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1"> <div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
@@ -97,7 +136,8 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
color="purple" color="purple"
/> />
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={students.length} value={students.length}
@@ -105,7 +145,8 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
/> />
)} )}
{allowedEntityStatistics.length > 0 && ( {allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={allowedEntityStatistics.length} value={allowedEntityStatistics.length}
@@ -124,26 +165,29 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={students.sort((a, b) =>
dateSorter(a, b, "desc", "registrationDate")
)}
title="Latest Students" title="Latest Students"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList
users={ users={students.sort(
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,24 +1,20 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch"; import { EntityWithRoles, Role } from "@/interfaces/entity";
import usePagination from "@/hooks/usePagination"; import { User } from "@/interfaces/user";
import { Entity, EntityWithRoles, Role } from "@/interfaces/entity";
import { GroupWithUsers, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntityWithRoles } from "@/utils/entities.be"; import { getEntityWithRoles } from "@/utils/entities.be";
import { convertToUsers, getGroup } from "@/utils/groups.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { checkAccess, doesEntityAllow, getTypesOfUser } from "@/utils/permissions"; import { doesEntityAllow } from "@/utils/permissions";
import { getUserName, isAdmin } from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
import { filterAllowedUsers, getEntitiesUsers, getEntityUsers, getLinkedUsers, getSpecificUsers, getUsers } from "@/utils/users.be"; import { filterAllowedUsers, getEntitiesUsers, getEntityUsers, getUsers } from "@/utils/users.be";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
@@ -28,7 +24,7 @@ import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { CURRENCIES } from "@/resources/paypal"; import { CURRENCIES } from "@/resources/paypal";
@@ -37,11 +33,9 @@ import {
BsChevronLeft, BsChevronLeft,
BsClockFill, BsClockFill,
BsEnvelopeFill, BsEnvelopeFill,
BsFillPersonVcardFill,
BsHash, BsHash,
BsPerson, BsPerson,
BsPlus, BsPlus,
BsSquare,
BsStopwatchFill, BsStopwatchFill,
BsTag, BsTag,
BsTrash, BsTrash,
@@ -344,7 +338,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -555,7 +549,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]} searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]}
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
@@ -152,7 +151,7 @@ interface Props {
disableEdit?: boolean disableEdit?: boolean
} }
export default function Role({ user, entity, role, userCount, disableEdit }: Props) { export default function EntityRole({ user, entity, role, userCount, disableEdit }: Props) {
const [permissions, setPermissions] = useState(role.permissions) const [permissions, setPermissions] = useState(role.permissions)
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -240,7 +239,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
@@ -388,7 +387,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
</div> </div>
</section> </section>
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,42 +1,25 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import {useListSearch} from "@/hooks/useListSearch"; import { EntityWithRoles, Role} from "@/interfaces/entity";
import usePagination from "@/hooks/usePagination"; import { User} from "@/interfaces/user";
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
import {GroupWithUsers, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import { redirect, serialize } from "@/utils"; import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import {getEntityWithRoles} from "@/utils/entities.be"; import {getEntityWithRoles} from "@/utils/entities.be";
import {convertToUsers, getGroup} from "@/utils/groups.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {checkAccess, doesEntityAllow, getTypesOfUser} from "@/utils/permissions"; import { doesEntityAllow} from "@/utils/permissions";
import {getUserName} from "@/utils/users"; import {getEntityUsers} from "@/utils/users.be";
import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import { import {
BsChevronLeft, BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus, BsPlus,
BsSquare,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs"; } from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
@@ -133,7 +116,7 @@ export default function Home({user, entity, roles, users}: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
@@ -152,7 +135,7 @@ export default function Home({user, entity, roles, users}: Props) {
<CardList list={roles} searchFields={[["label"]]} renderCard={renderCard} firstCard={canCreateRole ? firstCard : undefined} /> <CardList list={roles} searchFields={[["label"]]} renderCard={renderCard} firstCard={canCreateRole ? firstCard : undefined} />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,19 +1,16 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { mapBy, redirect, serialize } from "@/utils"; import { redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName } from "@/utils/users"; import { getUserName } from "@/utils/users";
import { getLinkedUsers, getUsers } from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
@@ -26,7 +23,6 @@ import { useState } from "react";
import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs"; import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
@@ -91,7 +87,7 @@ export default function Home({ user, users }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between"> <div className="flex gap-3 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -178,7 +174,7 @@ export default function Home({ user, users }: Props) {
</button> </button>
))} ))}
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -3,15 +3,12 @@ import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout"; import { User } from "@/interfaces/user";
import { GroupWithUsers, User } from "@/interfaces/user";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName, isAdmin } from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
import { convertToUsers, getGroupsForUser } from "@/utils/groups.be"; import { countEntityUsers, getEntityUsers } from "@/utils/users.be";
import { countEntityUsers, getEntityUsers, getSpecificUsers, getUsers } from "@/utils/users.be"; import { findAllowedEntities } from "@/utils/permissions";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import Link from "next/link"; import Link from "next/link";
import { uniq } from "lodash";
import { BsBank, BsPlus } from "react-icons/bs"; import { BsBank, BsPlus } from "react-icons/bs";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -20,24 +17,34 @@ import Separator from "@/components/Low/Separator";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
type EntitiesWithCount = { entity: EntityWithRoles; users: User[]; count: number }; type EntitiesWithCount = {
entity: EntityWithRoles;
users: User[];
count: number;
};
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(["admin", "developer"].includes(user.type) ? undefined : entityIDs); const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, 'view_entities') ["admin", "developer"].includes(user.type) ? undefined : entityIDs
);
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
const entitiesWithCount = await Promise.all( const entitiesWithCount = await Promise.all(
allowedEntities.map(async (e) => ({ allowedEntities.map(async (e) => ({
entity: e, entity: e,
count: await countEntityUsers(e.id, { type: { $in: ["student", "teacher", "corporate", "mastercorporate"] } }), count: await countEntityUsers(e.id, {
users: await getEntityUsers(e.id, 5, { type: { $in: ["student", "teacher", "corporate", "mastercorporate"] } }) type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
})), }),
users: await getEntityUsers(e.id, 5, {
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
}),
}))
); );
return { return {
@@ -56,19 +63,33 @@ export default function Home({ user, entities }: Props) {
<Link <Link
href={`/entities/${entity.id}`} href={`/entities/${entity.id}`}
key={entity.id} key={entity.id}
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span> <span className="bg-mti-purple text-white font-semibold px-2">
Entity
</span>
{entity.label} {entity.label}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Members</span> <span className="bg-mti-purple text-white font-semibold px-2">
<span className="bg-mti-purple-light/50 px-2">{count}{isAdmin(user) && ` / ${entity.licenses || 0}`}</span> Members
</span>
<span className="bg-mti-purple-light/50 px-2">
{count}
{isAdmin(user) && ` / ${entity.licenses || 0}`}
</span>
</span> </span>
<span> <span>
{users.map(getUserName).join(", ")}{' '} {users.map(getUserName).join(", ")}{" "}
{count > 5 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {count - 5} more</span> : ""} {count > 5 ? (
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
and {count - 5} more
</span>
) : (
""
)}
</span> </span>
</div> </div>
<div className="w-fit"> <div className="w-fit">
@@ -80,7 +101,8 @@ export default function Home({ user, entities }: Props) {
const firstCard = () => ( const firstCard = () => (
<Link <Link
href={`/entities/create`} href={`/entities/create`}
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
>
<BsPlus size={40} /> <BsPlus size={40} />
<span className="font-semibold">Create Entity</span> <span className="font-semibold">Create Entity</span>
</Link> </Link>
@@ -98,7 +120,7 @@ export default function Home({ user, entities }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="!gap-4"> <>
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Entities</h2> <h2 className="font-bold text-2xl">Entities</h2>
@@ -109,10 +131,12 @@ export default function Home({ user, entities }: Props) {
list={entities} list={entities}
searchFields={SEARCH_FIELDS} searchFields={SEARCH_FIELDS}
renderCard={renderCard} renderCard={renderCard}
firstCard={["admin", "developer"].includes(user.type) ? firstCard : undefined} firstCard={
["admin", "developer"].includes(user.type) ? firstCard : undefined
}
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -20,7 +20,6 @@ import { useRouter } from "next/router";
import { getSessionByAssignment } from "@/utils/sessions.be"; import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { activeAssignmentFilter } from "@/utils/assignments"; import { activeAssignmentFilter } from "@/utils/assignments";
import { checkAccess } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)

View File

@@ -2,24 +2,22 @@
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { toast, ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Radio, RadioGroup } from "@headlessui/react"; import { Radio, RadioGroup } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor"; import ExamEditor from "@/components/ExamEditor";
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getExam, getExams } from "@/utils/exams.be"; import { getExam, } from "@/utils/exams.be";
import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam"; import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -157,7 +155,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <>
<h1 className="text-2xl font-semibold">Exam Editor</h1> <h1 className="text-2xl font-semibold">Exam Editor</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input <Input
@@ -212,7 +210,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
</RadioGroup> </RadioGroup>
</div> </div>
<ExamEditor levelParts={examLevelParts} /> <ExamEditor levelParts={examLevelParts} />
</Layout> </>
)} )}
</> </>
); );

View File

@@ -1,6 +1,5 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import AssignmentCard from "@/components/High/AssignmentCard"; import AssignmentCard from "@/components/High/AssignmentCard";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
@@ -15,7 +14,10 @@ import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments"; import {
activeAssignmentFilter,
futureAssignmentFilter,
} from "@/utils/assignments";
import { getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
@@ -45,66 +47,87 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
const destination = Buffer.from(req.url || "/").toString("base64") const destination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${destination}`) if (!user) return redirect(`/login?destination=${destination}`);
if (!checkAccess(user, ["admin", "developer", "student"])) if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/") return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS); const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } }); const assignments = await getAssignmentsByAssignee(user.id, {
const sessions = await getSessionsByUser(user.id, 0, { "assignment.id": { $in: mapBy(assignments, 'id') } }); archived: { $ne: true },
});
const sessions = await getSessionsByUser(user.id, 0, {
"assignment.id": { $in: mapBy(assignments, "id") },
});
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.flatMap((a) =>
filterBy(a.exams, 'assignee', user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), filterBy(a.exams, "assignee", user.id).map(
(e: any) => ({
module: e.module,
id: e.id,
key: `${e.module}_${e.id}`,
})
)
), ),
"key", "key"
); );
const exams = await getExamsByIds(examIDs); const exams = await getExamsByIds(examIDs);
return { props: serialize({ user, entities, assignments, exams, sessions }) }; return { props: serialize({ user, entities, assignments, exams, sessions }) };
}, sessionOptions); }, sessionOptions);
const destination = Buffer.from("/official-exam").toString("base64") const destination = Buffer.from("/official-exam").toString("base64");
export default function OfficialExam({ user, entities, assignments, sessions, exams }: Props) { export default function OfficialExam({
const [isLoading, setIsLoading] = useState(false) user,
entities,
assignments,
sessions,
exams,
}: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
const reload = () => { const reload = () => {
setIsLoading(true) setIsLoading(true);
router.replace(router.asPath) router.replace(router.asPath);
setTimeout(() => setIsLoading(false), 500) setTimeout(() => setIsLoading(false), 500);
} };
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const assignmentExams = exams.filter(e => { const assignmentExams = exams.filter((e) => {
const exam = findBy(assignment.exams, 'id', e.id) const exam = findBy(assignment.exams, "id", e.id);
return !!exam && exam.module === e.module return !!exam && exam.module === e.module;
}) });
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
payload: {
exams: assignmentExams.sort(sortByModule), exams: assignmentExams.sort(sortByModule),
modules: mapBy(assignmentExams.sort(sortByModule), 'module'), modules: mapBy(assignmentExams.sort(sortByModule), "module"),
assignment assignment,
} },
}) });
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`); router.push(
`/exam?assignment=${assignment.id}&destination=${destination}`
);
} }
}; };
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
dispatch({ type: "SET_SESSION", payload: { session } }); dispatch({ type: "SET_SESSION", payload: { session } });
router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`); router.push(
`/exam?assignment=${session.assignment?.id}&destination=${destination}`
);
}; };
const logout = async () => { const logout = async () => {
@@ -113,12 +136,21 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
}); });
}; };
const studentAssignments = useMemo(() => [ const studentAssignments = useMemo(
...assignments.filter(activeAssignmentFilter), ...assignments.filter(futureAssignmentFilter)], () => [
...assignments.filter(activeAssignmentFilter),
...assignments.filter(futureAssignmentFilter),
],
[assignments] [assignments]
); );
const assignmentSessions = useMemo(() => sessions.filter(s => mapBy(studentAssignments, 'id').includes(s.assignment?.id || "")), [sessions, studentAssignments]) const assignmentSessions = useMemo(
() =>
sessions.filter((s) =>
mapBy(studentAssignments, "id").includes(s.assignment?.id || "")
),
[sessions, studentAssignments]
);
return ( return (
<> <>
@@ -132,7 +164,7 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} hideSidebar> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> <div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{mapBy(entities, "label")?.join(", ")}</b>
@@ -147,29 +179,44 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<div <div
onClick={reload} onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<span className="text-mti-black text-lg font-bold">Assignments</span> >
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} /> <span className="text-mti-black text-lg font-bold">
Assignments
</span>
<BsArrowRepeat
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."} {studentAssignments.length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{studentAssignments {studentAssignments
.sort((a, b) => moment(a.startDate).diff(b.startDate)) .sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((a) => .map((a) => (
<AssignmentCard <AssignmentCard
key={a.id} key={a.id}
assignment={a} assignment={a}
user={user} user={user}
session={assignmentSessions.find(s => s.assignment?.id === a.id)} session={assignmentSessions.find(
(s) => s.assignment?.id === a.id
)}
startAssignment={startAssignment} startAssignment={startAssignment}
resumeAssignment={loadSession} resumeAssignment={loadSession}
/> />
)} ))}
</span> </span>
</section> </section>
<Button onClick={logout} variant="outline" color="red" className="max-w-[200px] w-full absolute bottom-8 left-8">Sign out</Button> <Button
</Layout> onClick={logout}
variant="outline"
color="red"
className="max-w-[200px] w-full absolute bottom-8 left-8"
>
Sign out
</Button>
</>
</> </>
); );
} }

View File

@@ -2,14 +2,12 @@
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useUser from "@/hooks/useUser";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import usePayments from "@/hooks/usePayments"; import usePayments from "@/hooks/usePayments";
import usePaypalPayments from "@/hooks/usePaypalPayments"; import usePaypalPayments from "@/hooks/usePaypalPayments";
import { Payment, PaypalPayment } from "@/interfaces/paypal"; import { Payment, PaypalPayment } from "@/interfaces/paypal";
import { CellContext, createColumnHelper, flexRender, getCoreRowModel, HeaderGroup, Table, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, flexRender, getCoreRowModel, HeaderGroup, Table, useReactTable } from "@tanstack/react-table";
import { CURRENCIES } from "@/resources/paypal"; import { CURRENCIES } from "@/resources/paypal";
import { BsTrash } from "react-icons/bs"; import { BsTrash } from "react-icons/bs";
import axios from "axios"; import axios from "axios";
@@ -943,7 +941,7 @@ export default function PaymentRecord({ user, entities }: Props) {
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <>
{getUserModal()} {getUserModal()}
<Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}> <Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}>
<PaymentCreator <PaymentCreator
@@ -1248,7 +1246,7 @@ export default function PaymentRecord({ user, entities }: Props) {
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</Layout> </>
)} )}
</> </>
); );

View File

@@ -1,13 +1,13 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission, PermissionType } from "@/interfaces/permissions"; import { Permission, PermissionType } from "@/interfaces/permissions";
import { getPermissionDoc } from "@/utils/permissions.be"; import { getPermissionDoc } from "@/utils/permissions.be";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout"; import { LayoutContext } from "@/components/High/Layout";
import { getUsers } from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import { BsTrash } from "react-icons/bs"; import { BsTrash } from "react-icons/bs";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
@@ -30,13 +30,14 @@ interface PermissionWithBasicUsers {
users: BasicUser[]; users: BasicUser[];
} }
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
if (!params?.id) return redirect("/permissions") if (!params?.id) return redirect("/permissions");
// Fetch data from external API // Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string); const permission: Permission = await getPermissionDoc(params.id as string);
@@ -49,7 +50,9 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
user.type === "corporate" user.type === "corporate"
? userGroups ? userGroups
: user.type === "mastercorporate" : user.type === "mastercorporate"
? groups.filter((x) => userGroups.flatMap((y) => y.participants).includes(x.admin)) ? groups.filter((x) =>
userGroups.flatMap((y) => y.participants).includes(x.admin)
)
: groups; : groups;
const users = allUserData.map((u) => ({ const users = allUserData.map((u) => ({
@@ -59,17 +62,22 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
})) as BasicUser[]; })) as BasicUser[];
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type) const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) => filteredGroups.flatMap((g) => g.participants).includes(u.id)) ? users.filter((u) =>
filteredGroups.flatMap((g) => g.participants).includes(u.id)
)
: users; : users;
// const res = await fetch("api/permissions"); // const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json(); // const permissions: Permission[] = await res.json();
// Pass data to the page via props // Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce((acc: BasicUser[], userId) => { const usersData: BasicUser[] = permission.users.reduce(
(acc: BasicUser[], userId) => {
const user = filteredUsers.find((u) => u.id === userId) as BasicUser; const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
if (!!user) acc.push(user); if (!!user) acc.push(user);
return acc; return acc;
}, []); },
[]
);
return { return {
props: { props: {
@@ -83,7 +91,9 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
users: filteredUsers, users: filteredUsers,
}, },
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
permission: PermissionWithBasicUsers; permission: PermissionWithBasicUsers;
@@ -94,7 +104,9 @@ interface Props {
export default function Page(props: Props) { export default function Page(props: Props) {
const { permission, user, users } = props; const { permission, user, users } = props;
const [selectedUsers, setSelectedUsers] = useState<string[]>(() => permission.users.map((u) => u.id)); const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
permission.users.map((u) => u.id)
);
const onChange = (value: any) => { const onChange = (value: any) => {
setSelectedUsers((prev) => { setSelectedUsers((prev) => {
@@ -119,6 +131,13 @@ export default function Page(props: Props) {
} }
}; };
const { setClassName } = React.useContext(LayoutContext);
useEffect(() => {
setClassName("gap-6");
return () => setClassName("");
}, [setClassName]);
return ( return (
<> <>
<Head> <Head>
@@ -131,9 +150,11 @@ export default function Page(props: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="gap-6"> <>
<div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl"> <div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl">
<h1 className="text-2xl font-semibold">Permission: {permission.type as string}</h1> <h1 className="text-2xl font-semibold">
Permission: {permission.type as string}
</h1>
<div className="flex gap-3"> <div className="flex gap-3">
<Select <Select
value={null} value={null}
@@ -154,11 +175,18 @@ export default function Page(props: Props) {
{selectedUsers.map((userId) => { {selectedUsers.map((userId) => {
const user = users.find((u) => u.id === userId); const user = users.find((u) => u.id === userId);
return ( return (
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={userId}> <div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={userId}
>
<span className="text-base first-letter:uppercase"> <span className="text-base first-letter:uppercase">
{user?.type}-{user?.name} {user?.type}-{user?.name}
</span> </span>
<BsTrash style={{cursor: "pointer"}} onClick={() => removeUser(userId)} size={20} /> <BsTrash
style={{ cursor: "pointer" }}
onClick={() => removeUser(userId)}
size={20}
/>
</div> </div>
); );
})} })}
@@ -171,7 +199,10 @@ export default function Page(props: Props) {
.filter((user) => !selectedUsers.includes(user.id)) .filter((user) => !selectedUsers.includes(user.id))
.map((user) => { .map((user) => {
return ( return (
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={user.id}> <div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={user.id}
>
<span className="text-base first-letter:uppercase"> <span className="text-base first-letter:uppercase">
{user?.type}-{user?.name} {user?.type}-{user?.name}
</span> </span>
@@ -182,7 +213,7 @@ export default function Page(props: Props) {
</div> </div>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -6,24 +6,33 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { Permission } from "@/interfaces/permissions"; import { Permission } from "@/interfaces/permissions";
import { getPermissionDocs } from "@/utils/permissions.be"; import { getPermissionDocs } from "@/utils/permissions.be";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout"; import { LayoutContext } from "@/components/High/Layout";
import PermissionList from "@/components/PermissionList"; import PermissionList from "@/components/PermissionList";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
import React from "react";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
// Fetch data from external API // Fetch data from external API
const permissions: Permission[] = await getPermissionDocs(); const permissions: Permission[] = await getPermissionDocs();
const filteredPermissions = permissions.filter((p) => { const filteredPermissions = permissions.filter((p) => {
const permissionType = p.type.toString().toLowerCase(); const permissionType = p.type.toString().toLowerCase();
if (user.type === "corporate") return !permissionType.includes("corporate") && !permissionType.includes("admin"); if (user.type === "corporate")
if (user.type === "mastercorporate") return !permissionType.includes("mastercorporate") && !permissionType.includes("admin"); return (
!permissionType.includes("corporate") &&
!permissionType.includes("admin")
);
if (user.type === "mastercorporate")
return (
!permissionType.includes("mastercorporate") &&
!permissionType.includes("admin")
);
return true; return true;
}); });
@@ -51,6 +60,9 @@ interface Props {
export default function Page(props: Props) { export default function Page(props: Props) {
const { permissions, user } = props; const { permissions, user } = props;
const { setClassName } = React.useContext(LayoutContext);
React.useEffect(() => setClassName("gap-6"), [setClassName]);
return ( return (
<> <>
<Head> <Head>
@@ -62,12 +74,12 @@ export default function Page(props: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user} className="gap-6"> <>
<h1 className="text-2xl font-semibold">Permissions</h1> <h1 className="text-2xl font-semibold">Permissions</h1>
<div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl"> <div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
<PermissionList permissions={permissions} /> <PermissionList permissions={permissions} />
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -2,10 +2,16 @@
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { ChangeEvent, Dispatch, ReactNode, SetStateAction, useEffect, useRef, useState } from "react"; import {
ChangeEvent,
Dispatch,
ReactNode,
SetStateAction,
useRef,
useState,
} from "react";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Link from "next/link"; import Link from "next/link";
@@ -15,26 +21,21 @@ import clsx from "clsx";
import { import {
CorporateUser, CorporateUser,
EmploymentStatus, EmploymentStatus,
EMPLOYMENT_STATUS,
Gender, Gender,
User, User,
DemographicInformation, DemographicInformation,
MasterCorporateUser, MasterCorporateUser,
Group,
} from "@/interfaces/user"; } from "@/interfaces/user";
import CountrySelect from "@/components/Low/CountrySelect"; import CountrySelect from "@/components/Low/CountrySelect";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import moment from "moment"; import moment from "moment";
import { BsCamera, BsQuestionCircleFill } from "react-icons/bs"; import { BsCamera, BsQuestionCircleFill } from "react-icons/bs";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import { convertBase64, redirect } from "@/utils"; import { convertBase64, redirect } from "@/utils";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import GenderInput from "@/components/High/GenderInput"; import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput"; import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
import TimezoneSelect from "@/components/Low/TImezoneSelect"; import TimezoneSelect from "@/components/Low/TImezoneSelect";
import Modal from "@/components/Modal";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector"; import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
@@ -42,39 +43,65 @@ import { InstructorGender } from "@/interfaces/exam";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import TopicModal from "@/components/Medium/TopicModal"; import TopicModal from "@/components/Medium/TopicModal";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getParticipantGroups, getUserCorporate } from "@/utils/groups.be"; import { getParticipantGroups, getUserCorporate } from "@/utils/groups.be";
import { InferGetServerSidePropsType } from "next"; import { countUsers, getUser } from "@/utils/users.be";
import { getUsers } from "@/utils/users.be";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const linkedCorporate = (await getUserCorporate(user.id)) || null;
const groups = (
await getParticipantGroups(user.id, { _id: 0, admin: 1 })
).map((group) => group.admin);
const referralAgent =
user.type === "corporate" && user.corporateInformation.referralAgent
? await getUser(user.corporateInformation.referralAgent, {
_id: 0,
name: 1,
email: 1,
demographicInformation: 1,
})
: null;
const hasBenefitsFromUniversity =
(await countUsers({
id: { $in: groups },
type: "corporate",
})) > 0;
return { return {
props: { props: {
user, user,
linkedCorporate: (await getUserCorporate(user.id)) || null, linkedCorporate,
groups: await getParticipantGroups(user.id), hasBenefitsFromUniversity,
users: await getUsers(), referralAgent,
}, },
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
groups: Group[]; hasBenefitsFromUniversity: boolean;
users: User[];
mutateUser: Function; mutateUser: Function;
referralAgent?: User;
linkedCorporate?: CorporateUser | MasterCorporateUser; linkedCorporate?: CorporateUser | MasterCorporateUser;
} }
const DoubleColumnRow = ({ children }: { children: ReactNode }) => <div className="flex flex-col lg:flex-row gap-8 w-full">{children}</div>; const DoubleColumnRow = ({ children }: { children: ReactNode }) => (
<div className="flex flex-col lg:flex-row gap-8 w-full">{children}</div>
);
function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props) { function UserProfile({
user,
mutateUser,
linkedCorporate,
hasBenefitsFromUniversity,
referralAgent,
}: Props) {
const [bio, setBio] = useState(user.bio || ""); const [bio, setBio] = useState(user.bio || "");
const [name, setName] = useState(user.name || ""); const [name, setName] = useState(user.name || "");
const [email, setEmail] = useState(user.email || ""); const [email, setEmail] = useState(user.email || "");
@@ -83,37 +110,69 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [profilePicture, setProfilePicture] = useState(user.profilePicture); const [profilePicture, setProfilePicture] = useState(user.profilePicture);
const [desiredLevels, setDesiredLevels] = useState(checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined); const [desiredLevels, setDesiredLevels] = useState(
checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined
);
const [focus, setFocus] = useState<"academic" | "general">(user.focus); const [focus, setFocus] = useState<"academic" | "general">(user.focus);
const [country, setCountry] = useState<string>(user.demographicInformation?.country || ""); const [country, setCountry] = useState<string>(
const [phone, setPhone] = useState<string>(user.demographicInformation?.phone || ""); user.demographicInformation?.country || ""
const [gender, setGender] = useState<Gender | undefined>(user.demographicInformation?.gender || undefined); );
const [phone, setPhone] = useState<string>(
user.demographicInformation?.phone || ""
);
const [gender, setGender] = useState<Gender | undefined>(
user.demographicInformation?.gender || undefined
);
const [employment, setEmployment] = useState<EmploymentStatus | undefined>( const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
checkAccess(user, ["corporate", "mastercorporate"]) ? undefined : (user.demographicInformation as DemographicInformation)?.employment, checkAccess(user, ["corporate", "mastercorporate"])
? undefined
: (user.demographicInformation as DemographicInformation)?.employment
); );
const [passport_id, setPassportID] = useState<string | undefined>( const [passport_id, setPassportID] = useState<string | undefined>(
checkAccess(user, ["student"]) ? (user.demographicInformation as DemographicInformation)?.passport_id : undefined, checkAccess(user, ["student"])
? (user.demographicInformation as DemographicInformation)?.passport_id
: undefined
); );
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>( const [preferredGender, setPreferredGender] = useState<
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined, InstructorGender | undefined
>(
user.type === "student" || user.type === "developer"
? user.preferredGender || "varied"
: undefined
); );
const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>( const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>(
user.type === "student" || user.type === "developer" ? user.preferredTopics : undefined, user.type === "student" || user.type === "developer"
? user.preferredTopics
: undefined
); );
const [position, setPosition] = useState<string | undefined>( const [position, setPosition] = useState<string | undefined>(
user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined, user.type === "corporate" || user.type === "mastercorporate"
? user.demographicInformation?.position
: undefined
); );
const [corporateInformation, setCorporateInformation] = useState( const [corporateInformation, setCorporateInformation] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation : undefined, user.type === "corporate" || user.type === "mastercorporate"
? user.corporateInformation
: undefined
); );
const [companyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined); const [companyName] = useState<string | undefined>(
const [commercialRegistration] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined); user.type === "agent" ? user.agentInformation?.companyName : undefined
const [arabName, setArabName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyArabName : undefined); );
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess()); const [commercialRegistration] = useState<string | undefined>(
user.type === "agent"
? user.agentInformation?.commercialRegistration
: undefined
);
const [arabName, setArabName] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.companyArabName : undefined
);
const [timezone, setTimezone] = useState<string>(
user.demographicInformation?.timezone || moment.tz.guess()
);
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false); const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
const profilePictureInput = useRef(null); const profilePictureInput = useRef(null);
@@ -121,9 +180,12 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(1, "days").isAfter(momentDate))
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
}; };
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => { const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
@@ -143,15 +205,15 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
} }
if (newPassword && !password) { if (newPassword && !password) {
toast.error("To update your password you need to input your current one!"); toast.error(
"To update your password you need to input your current one!"
);
setIsLoading(false); setIsLoading(false);
return; return;
} }
if (email !== user?.email) { if (email !== user?.email) {
const userAdmins = groups.filter((x) => x.participants.includes(user.id)).map((x) => x.admin); const message = hasBenefitsFromUniversity
const message =
users.filter((x) => userAdmins.includes(x.id) && x.type === "corporate").length > 0
? "If you change your e-mail address, you will lose all benefits from your university/institute. Are you sure you want to continue?" ? "If you change your e-mail address, you will lose all benefits from your university/institute. Are you sure you want to continue?"
: "Are you sure you want to update your e-mail address?"; : "Are you sure you want to update your e-mail address?";
@@ -212,7 +274,9 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
const ExpirationDate = () => ( const ExpirationDate = () => (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label> <label className="font-normal text-base text-mti-gray-dim">
Expiry Date (click to purchase)
</label>
<Link <Link
href="/payment" href="/payment"
className={clsx( className={clsx(
@@ -221,33 +285,45 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
!user.subscriptionExpirationDate !user.subscriptionExpirationDate
? "!bg-mti-green-ultralight !border-mti-green-light" ? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate), : expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum", "bg-white border-mti-gray-platinum"
)}> )}
>
{!user.subscriptionExpirationDate && "Unlimited"} {!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")} {user.subscriptionExpirationDate &&
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link> </Link>
</div> </div>
); );
const TimezoneInput = () => ( const TimezoneInput = () => (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Timezone</label> <label className="font-normal text-base text-mti-gray-dim">
Timezone
</label>
<TimezoneSelect value={timezone} onChange={setTimezone} /> <TimezoneSelect value={timezone} onChange={setTimezone} />
</div> </div>
); );
const manualDownloadLink = ["student", "teacher", "corporate"].includes(user.type) ? `/manuals/${user.type}.pdf` : ""; const manualDownloadLink = ["student", "teacher", "corporate"].includes(
user.type
)
? `/manuals/${user.type}.pdf`
: "";
return ( return (
<Layout user={user}> <>
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8"> <section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
<h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1> <h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1>
<div className="flex -md:flex-col-reverse -md:items-center w-full justify-between"> <div className="flex -md:flex-col-reverse -md:items-center w-full justify-between">
<div className="flex flex-col gap-8 w-full md:w-2/3"> <div className="flex flex-col gap-8 w-full md:w-2/3">
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1> <h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
<form className="flex flex-col items-center gap-6 w-full" onSubmit={(e) => e.preventDefault()}> <form
className="flex flex-col items-center gap-6 w-full"
onSubmit={(e) => e.preventDefault()}
>
<DoubleColumnRow> <DoubleColumnRow>
{user.type !== "corporate" && user.type !== "mastercorporate" && ( {user.type !== "corporate" &&
user.type !== "mastercorporate" && (
<Input <Input
label={user.type === "agent" ? "English name" : "Name"} label={user.type === "agent" ? "English name" : "Name"}
type="text" type="text"
@@ -323,7 +399,9 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<DoubleColumnRow> <DoubleColumnRow>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label> <label className="font-normal text-base text-mti-gray-dim">
Country *
</label>
<CountrySelect value={country} onChange={setCountry} /> <CountrySelect value={country} onChange={setCountry} />
</div> </div>
<Input <Input
@@ -356,26 +434,37 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<Divider /> <Divider />
{desiredLevels && ["developer", "student"].includes(user.type) && ( {desiredLevels &&
["developer", "student"].includes(user.type) && (
<> <>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Desired Levels</label> <label className="font-normal text-base text-mti-gray-dim">
Desired Levels
</label>
<ModuleLevelSelector <ModuleLevelSelector
levels={desiredLevels} levels={desiredLevels}
setLevels={setDesiredLevels as Dispatch<SetStateAction<{ [key in Module]: number }>>} setLevels={
setDesiredLevels as Dispatch<
SetStateAction<{ [key in Module]: number }>
>
}
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Focus</label> <label className="font-normal text-base text-mti-gray-dim">
Focus
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 w-full"> <div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 w-full">
<button <button
onClick={() => setFocus("academic")} onClick={() => setFocus("academic")}
className={clsx( className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white", "w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white", "hover:bg-mti-purple-light hover:text-white",
focus === "academic" && "!bg-mti-purple-light !text-white", focus === "academic" &&
"transition duration-300 ease-in-out", "!bg-mti-purple-light !text-white",
)}> "transition duration-300 ease-in-out"
)}
>
Academic Academic
</button> </button>
<button <button
@@ -383,9 +472,11 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
className={clsx( className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white", "w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white", "hover:bg-mti-purple-light hover:text-white",
focus === "general" && "!bg-mti-purple-light !text-white", focus === "general" &&
"transition duration-300 ease-in-out", "!bg-mti-purple-light !text-white",
)}> "transition duration-300 ease-in-out"
)}
>
General General
</button> </button>
</div> </div>
@@ -393,18 +484,27 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
</> </>
)} )}
{preferredGender && ["developer", "student"].includes(user.type) && ( {preferredGender &&
["developer", "student"].includes(user.type) && (
<> <>
<Divider /> <Divider />
<DoubleColumnRow> <DoubleColumnRow>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select <Select
value={{ value={{
value: preferredGender, value: preferredGender,
label: capitalize(preferredGender), label: capitalize(preferredGender),
}} }}
onChange={(value) => (value ? setPreferredGender(value.value as InstructorGender) : null)} onChange={(value) =>
value
? setPreferredGender(
value.value as InstructorGender
)
: null
}
options={[ options={[
{ value: "male", label: "Male" }, { value: "male", label: "Male" },
{ value: "female", label: "Female" }, { value: "female", label: "Female" },
@@ -417,12 +517,18 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
Preferred Topics{" "} Preferred Topics{" "}
<span <span
className="tooltip" className="tooltip"
data-tip="These topics will be considered for speaking and writing modules, aiming to include at least one exercise containing of the these in the selected exams."> data-tip="These topics will be considered for speaking and writing modules, aiming to include at least one exercise containing of the these in the selected exams."
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</span> </span>
</label> </label>
<Button className="w-full" variant="outline" onClick={() => setIsPreferredTopicsOpen(true)}> <Button
Select Topics ({preferredTopics?.length || "All"} selected) className="w-full"
variant="outline"
onClick={() => setIsPreferredTopicsOpen(true)}
>
Select Topics ({preferredTopics?.length || "All"}{" "}
selected)
</Button> </Button>
</div> </div>
</DoubleColumnRow> </DoubleColumnRow>
@@ -483,14 +589,16 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
</> </>
)} )}
{user.type === "corporate" && user.corporateInformation.referralAgent && ( {user.type === "corporate" &&
user.corporateInformation.referralAgent &&
referralAgent && (
<> <>
<Divider /> <Divider />
<DoubleColumnRow> <DoubleColumnRow>
<Input <Input
name="agentName" name="agentName"
onChange={() => null} onChange={() => null}
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.name} defaultValue={referralAgent?.name}
type="text" type="text"
label="Country Manager's Name" label="Country Manager's Name"
placeholder="Not available" placeholder="Not available"
@@ -500,7 +608,7 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<Input <Input
name="agentEmail" name="agentEmail"
onChange={() => null} onChange={() => null}
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.email} defaultValue={referralAgent?.email}
type="text" type="text"
label="Country Manager's E-mail" label="Country Manager's E-mail"
placeholder="Not available" placeholder="Not available"
@@ -510,12 +618,11 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
</DoubleColumnRow> </DoubleColumnRow>
<DoubleColumnRow> <DoubleColumnRow>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country Manager&apos;s Country *</label> <label className="font-normal text-base text-mti-gray-dim">
Country Manager&apos;s Country *
</label>
<CountrySelect <CountrySelect
value={ value={referralAgent?.demographicInformation?.country}
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation
?.country
}
onChange={() => null} onChange={() => null}
disabled disabled
/> />
@@ -528,7 +635,7 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
onChange={() => null} onChange={() => null}
placeholder="Not available" placeholder="Not available"
defaultValue={ defaultValue={
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation?.phone referralAgent?.demographicInformation?.phone
} }
disabled disabled
required required
@@ -539,7 +646,10 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
{user.type !== "corporate" && ( {user.type !== "corporate" && (
<DoubleColumnRow> <DoubleColumnRow>
<EmploymentStatusInput value={employment} onChange={setEmployment} /> <EmploymentStatusInput
value={employment}
onChange={setEmployment}
/>
<div className="flex flex-col gap-8 w-full"> <div className="flex flex-col gap-8 w-full">
<GenderInput value={gender} onChange={setGender} /> <GenderInput value={gender} onChange={setGender} />
@@ -552,37 +662,62 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<div className="flex flex-col gap-6 w-48"> <div className="flex flex-col gap-6 w-48">
<div <div
className="flex flex-col gap-3 items-center h-fit cursor-pointer group" className="flex flex-col gap-3 items-center h-fit cursor-pointer group"
onClick={() => (profilePictureInput.current as any)?.click()}> onClick={() => (profilePictureInput.current as any)?.click()}
>
<div className="relative overflow-hidden h-48 w-48 rounded-full"> <div className="relative overflow-hidden h-48 w-48 rounded-full">
<div <div
className={clsx( className={clsx(
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100", "absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
"transition ease-in-out duration-300", "transition ease-in-out duration-300"
)}> )}
>
<BsCamera className="text-6xl text-mti-purple-ultralight/80" /> <BsCamera className="text-6xl text-mti-purple-ultralight/80" />
</div> </div>
<img src={profilePicture} alt={user.name} className="aspect-square drop-shadow-xl self-end object-cover" /> <img
src={profilePicture}
alt={user.name}
className="aspect-square drop-shadow-xl self-end object-cover"
/>
</div> </div>
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} /> <input
type="file"
className="hidden"
onChange={uploadProfilePicture}
accept="image/*"
ref={profilePictureInput}
/>
<span <span
onClick={() => (profilePictureInput.current as any)?.click()} onClick={() => (profilePictureInput.current as any)?.click()}
className="cursor-pointer text-mti-purple-light text-sm"> className="cursor-pointer text-mti-purple-light text-sm"
>
Change picture Change picture
</span> </span>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6> <h6 className="font-normal text-base text-mti-gray-taupe">
{USER_TYPE_LABELS[user.type]}
</h6>
</div> </div>
{user.type === "agent" && ( {user.type === "agent" && (
<div className="flag items-center h-fit"> <div className="flag items-center h-fit">
<img <img
alt={user.demographicInformation?.country.toLowerCase() + "_flag"} alt={
user.demographicInformation?.country.toLowerCase() + "_flag"
}
src={`https://flagcdn.com/w320/${user.demographicInformation?.country.toLowerCase()}.png`} src={`https://flagcdn.com/w320/${user.demographicInformation?.country.toLowerCase()}.png`}
width="320" width="320"
/> />
</div> </div>
)} )}
{manualDownloadLink && ( {manualDownloadLink && (
<a href={manualDownloadLink} className="max-w-[200px] self-end w-full" download> <a
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full"> href={manualDownloadLink}
className="max-w-[200px] self-end w-full"
download
>
<Button
color="purple"
variant="outline"
className="max-w-[200px] self-end w-full"
>
Download Manual Download Manual
</Button> </Button>
</a> </a>
@@ -601,20 +736,33 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Link href="/" className="max-w-[200px] self-end w-full"> <Link href="/" className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full"> <Button
color="purple"
variant="outline"
className="max-w-[200px] self-end w-full"
>
Back Back
</Button> </Button>
</Link> </Link>
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}> <Button
color="purple"
className="max-w-[200px] self-end w-full"
onClick={updateUser}
disabled={isLoading}
>
Save Changes Save Changes
</Button> </Button>
</div> </div>
</section> </section>
</Layout> </>
); );
} }
export default function Home(props: { linkedCorporate?: CorporateUser | MasterCorporateUser; groups: Group[]; users: User[] }) { export default function Home(props: {
hasBenefitsFromUniversity: boolean;
referralAgent?: User;
linkedCorporate?: CorporateUser | MasterCorporateUser;
}) {
const { user, mutateUser } = useUser({ redirectTo: "/login" }); const { user, mutateUser } = useUser({ redirectTo: "/login" });
return ( return (

View File

@@ -8,7 +8,6 @@ import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { groupByDate } from "@/utils/stats"; import { groupByDate } from "@/utils/stats";
import moment from "moment"; import moment from "moment";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { uuidv4 } from "@firebase/util"; import { uuidv4 } from "@firebase/util";
@@ -201,7 +200,7 @@ export default function History({ user, users, assignments, entities, gradingSys
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user}> <>
<RecordFilter user={user} isAdmin={isAdmin} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}> <RecordFilter user={user} isAdmin={isAdmin} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}>
{training && ( {training && (
<div className="flex flex-row"> <div className="flex flex-row">
@@ -234,7 +233,7 @@ export default function History({ user, users, assignments, entities, gradingSys
<span className="loading loading-infinity w-32 bg-mti-green-light" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
</div> </div>
)} )}
</Layout> </>
)} )}
</> </>
); );

View File

@@ -8,9 +8,7 @@ import clsx from "clsx";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import RegisterIndividual from "./(register)/RegisterIndividual"; import RegisterIndividual from "./(register)/RegisterIndividual";
import RegisterCorporate from "./(register)/RegisterCorporate"; import RegisterCorporate from "./(register)/RegisterCorporate";
import EmailVerification from "./(auth)/EmailVerification";
import {sendEmailVerification} from "@/utils/email"; import {sendEmailVerification} from "@/utils/email";
import useUsers from "@/hooks/useUsers";
import axios from "axios"; import axios from "axios";
export const getServerSideProps = (context: any) => { export const getServerSideProps = (context: any) => {

View File

@@ -3,17 +3,13 @@ import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import CodeGenerator from "./(admin)/CodeGenerator"; import CodeGenerator from "./(admin)/CodeGenerator";
import ExamLoader from "./(admin)/ExamLoader"; import ExamLoader from "./(admin)/ExamLoader";
import { Tab } from "@headlessui/react";
import clsx from "clsx";
import Lists from "./(admin)/Lists"; import Lists from "./(admin)/Lists";
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator"; import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser"; import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
import { useState } from "react"; import { useState } from "react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
@@ -23,10 +19,9 @@ import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
import { CEFR_STEPS } from "@/resources/grading"; import { CEFR_STEPS } from "@/resources/grading";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { getUserPermissions } from "@/utils/permissions.be"; import { getUserPermissions } from "@/utils/permissions.be";
import { Permission, PermissionType } from "@/interfaces/permissions"; import { PermissionType } from "@/interfaces/permissions";
import { getUsers } from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import useUsers from "@/hooks/useUsers"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
import { mapBy, serialize, redirect } from "@/utils"; import { mapBy, serialize, redirect } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
@@ -84,7 +79,7 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="gap-6"> <>
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]"> <Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]">
<BatchCreateUser <BatchCreateUser
user={user} user={user}
@@ -180,7 +175,7 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
<section className="w-full"> <section className="w-full">
<Lists user={user} entities={entities} permissions={permissions} /> <Lists user={user} entities={entities} permissions={permissions} />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
@@ -8,16 +7,15 @@ import { Session } from "@/hooks/useSessions";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { Assignment, AssignmentResult } from "@/interfaces/results"; import { Assignment, AssignmentResult } from "@/interfaces/results";
import { Group, Stat, StudentUser, User } from "@/interfaces/user"; import { StudentUser, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getEntitiesAssignments } from "@/utils/assignments.be"; import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { getSessionsByAssignments, getSessionsByUser } from "@/utils/sessions.be"; import { getSessionsByAssignments } from "@/utils/sessions.be";
import { getStatsByUsers } from "@/utils/stats.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { createColumnHelper } from "@tanstack/react-table"; import { createColumnHelper } from "@tanstack/react-table";
@@ -222,7 +220,7 @@ export default function Statistical({ user, students, entities, assignments, ses
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -301,7 +299,7 @@ export default function Statistical({ user, students, entities, assignments, ses
isDownloadLoading={isDownloading} isDownloadLoading={isDownloading}
/> />
)} )}
</Layout> </>
</> </>
) )
} }

View File

@@ -21,9 +21,8 @@ import {
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useStats from "@/hooks/useStats";
import { import {
averageScore,
groupBySession, groupBySession,
groupByModule, groupByModule,
timestampToMoment, timestampToMoment,
@@ -32,11 +31,8 @@ import { ToastContainer } from "react-toastify";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import { calculateBandScore } from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import { import {
countExamModules,
countFullExams,
MODULE_ARRAY, MODULE_ARRAY,
sortByModule, sortByModule,
} from "@/utils/moduleUtils"; } from "@/utils/moduleUtils";
@@ -109,7 +105,7 @@ export default function Stats({ user, entities, isAdmin }: Props) {
const [selectedEntity, setSelectedEntity] = useState<string>(); const [selectedEntity, setSelectedEntity] = useState<string>();
const entitiesToSearch = useMemo(() => { const entitiesToSearch = useMemo(() => {
if(selectedEntity) return [selectedEntity] if (selectedEntity) return [selectedEntity];
if (isAdmin) return undefined; if (isAdmin) return undefined;
return mapBy(entities, "id"); return mapBy(entities, "id");
}, [entities, isAdmin, selectedEntity]); }, [entities, isAdmin, selectedEntity]);
@@ -137,10 +133,19 @@ export default function Stats({ user, entities, isAdmin }: Props) {
const [intervalDates, setIntervalDates] = useState<Date[]>([]); const [intervalDates, setIntervalDates] = useState<Date[]>([]);
const { data: stats } = useFilterRecordsByUser<Stat[]>( const {
statsUserId, data: {
!statsUserId allStats: stats = [],
); fullExams: exams = 0,
uniqueModules: modules = 0,
averageScore = 0,
},
} = useStats<{
allStats: Stat[];
fullExams: number;
uniqueModules: number;
averageScore: number;
}>(statsUserId, !statsUserId, "stats");
const initialStatDate = useMemo( const initialStatDate = useMemo(
() => (stats[0] ? timestampToMoment(stats[0]).toDate() : null), () => (stats[0] ? timestampToMoment(stats[0]).toDate() : null),
@@ -239,7 +244,7 @@ export default function Stats({ user, entities, isAdmin }: Props) {
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-8"> <>
<ProfileSummary <ProfileSummary
user={userData || user} user={userData || user}
items={[ items={[
@@ -247,7 +252,7 @@ export default function Stats({ user, entities, isAdmin }: Props) {
icon: ( icon: (
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" /> <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
), ),
value: countFullExams(stats), value: exams,
label: "Exams", label: "Exams",
tooltip: "Number of all conducted completed exams", tooltip: "Number of all conducted completed exams",
}, },
@@ -255,7 +260,7 @@ export default function Stats({ user, entities, isAdmin }: Props) {
icon: ( icon: (
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" /> <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
), ),
value: countExamModules(stats), value: modules,
label: "Modules", label: "Modules",
tooltip: tooltip:
"Number of all exam modules performed including Level Test", "Number of all exam modules performed including Level Test",
@@ -264,7 +269,7 @@ export default function Stats({ user, entities, isAdmin }: Props) {
icon: ( icon: (
<BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" /> <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
), ),
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, value: `${averageScore.toFixed(2) || 0}%`,
label: "Average Score", label: "Average Score",
tooltip: "Average success rate for questions responded", tooltip: "Average success rate for questions responded",
}, },
@@ -888,7 +893,7 @@ export default function Stats({ user, entities, isAdmin }: Props) {
<span className="font-semibold ml-1">No stats to display...</span> <span className="font-semibold ml-1">No stats to display...</span>
</section> </section>
)} )}
</Layout> </>
)} )}
</> </>
); );

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import TicketDisplay from "@/components/High/TicketDisplay"; import TicketDisplay from "@/components/High/TicketDisplay";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
@@ -228,7 +227,7 @@ export default function Tickets() {
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <>
<h1 className="text-2xl font-semibold">Tickets</h1> <h1 className="text-2xl font-semibold">Tickets</h1>
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
@@ -317,7 +316,7 @@ export default function Tickets() {
))} ))}
</tbody> </tbody>
</table> </table>
</Layout> </>
)} )}
</> </>
); );

View File

@@ -13,7 +13,6 @@ import { ITrainingContent, ITrainingTip } from "@/training/TrainingInterfaces";
import formatTip from "@/training/FormatTip"; import formatTip from "@/training/FormatTip";
import { Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import Head from "next/head"; import Head from "next/head";
import Layout from "@/components/High/Layout";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
@@ -149,7 +148,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
{loading ? ( {loading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
@@ -343,7 +342,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
</div> </div>
) )
)} )}
</Layout> </>
</> </>
); );
}; };

View File

@@ -4,7 +4,6 @@ import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {ToastContainer} from "react-toastify"; import {ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import clsx from "clsx"; import clsx from "clsx";
@@ -22,10 +21,8 @@ import RecordFilter from "@/components/Medium/RecordFilter";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
@@ -179,7 +176,7 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
{isNewContentLoading || areRecordsLoading ? ( {isNewContentLoading || areRecordsLoading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
@@ -220,7 +217,7 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
)} )}
</> </>
)} )}
</Layout> </>
</> </>
); );
}; };

View File

@@ -1,6 +1,4 @@
import Layout from "@/components/High/Layout";
import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers";
import { Type, User } from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
@@ -10,8 +8,7 @@ import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {withIronSessionSsr} from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect} from "react"; import { BsChevronLeft} from "react-icons/bs";
import {BsArrowLeft, BsChevronLeft} from "react-icons/bs";
import {ToastContainer} from "react-toastify"; import {ToastContainer} from "react-toastify";
import UserList from "../(admin)/Lists/UserList"; import UserList from "../(admin)/Lists/UserList";
@@ -51,7 +48,7 @@ export default function UsersListPage({ user, type }: Props) {
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<UserList <UserList
user={user} user={user}
type={type} type={type}
@@ -70,7 +67,7 @@ export default function UsersListPage({ user, type }: Props) {
</div> </div>
)} )}
/> />
</Layout> </>
</> </>
); );
} }

View File

@@ -1,27 +1,22 @@
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useGroups from "@/hooks/useGroups";
import useUsers, { userHashStudent } from "@/hooks/useUsers";
import { Group, Stat, StudentUser, User } from "@/interfaces/user"; import { Group, Stat, StudentUser, User } from "@/interfaces/user";
import { getUserCompanyName } from "@/resources/user";
import clsx from "clsx";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { BsArrowLeft, BsArrowRepeat, BsChevronLeft } from "react-icons/bs"; import { BsChevronLeft } from "react-icons/bs";
import { mapBy, serialize } from "@/utils"; import { mapBy, serialize } from "@/utils";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { Entity } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { getParticipantGroups, getParticipantsGroups } from "@/utils/groups.be"; import { getParticipantsGroups } from "@/utils/groups.be";
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList"; import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
import Head from "next/head"; import Head from "next/head";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") if (!user) return redirect("/login")
@@ -74,7 +69,7 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
@@ -86,7 +81,7 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2> <h2 className="font-bold text-2xl">Student Performance ({students.length})</h2>
</div> </div>
<StudentPerformanceList items={performanceStudents} stats={stats} /> <StudentPerformanceList items={performanceStudents} stats={stats} />
</Layout> </>
</> </>
); );
}; };

View File

@@ -1,6 +1,5 @@
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { IncomingMessage, ServerResponse } from "http"; import { IncomingMessage, ServerResponse } from "http";
import { IronSession } from "iron-session";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getUser } from "./users.be"; import { getUser } from "./users.be";

View File

@@ -27,13 +27,72 @@ export const getAssignment = async (id: string) => {
return await db.collection("assignments").findOne<Assignment>({ id }); return await db.collection("assignments").findOne<Assignment>({ id });
}; };
export const getAssignmentsByAssignee = async (id: string, filter?: { [key in keyof Partial<Assignment>]: any }) => { export const getAssignmentsByAssignee = async (id: string, filter?: {}, projection?: {}, sort?: {}) => {
return await db return await db.collection("assignments")
.collection("assignments") .aggregate([
.find<Assignment>({ assignees: id, ...(!filter ? {} : filter) }) { $match: { assignees: id, ...(!filter ? {} : filter) } },
...(sort ? [{ $sort: sort }] : []),
...(projection ? [{ $project: projection }] : []),
])
.toArray(); .toArray();
}; };
export const getAssignmentsForStudent = async (id: string, currentDate: string) => {
return await db.collection("assignments")
.aggregate([{
$match: {
assignees: id, archived: { $ne: true },
endDate: { $gte: currentDate },
$or: [
{ autoStart: true, startDate: { $lt: currentDate } },
{ start: true },
],
}
},
{ $sort: { startDate: 1 } },
{
$project: {
id: 1,
name: 1,
startDate: 1,
endDate: 1,
exams: {
$sortArray: {
input: {
$filter: {
input: "$exams",
as: "exam",
cond: { $eq: ["$$exam.assignee", id] },
},
},
sortBy: { module: 1 },
},
},
hasResults: {
$cond: {
if: {
$gt: [
{
$size: {
$filter: {
input: "$results",
as: "result",
cond: { $eq: ["$$result.userId", id] },
},
},
},
0,
],
},
then: true,
else: false,
},
}
}
}
]).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();
}; };

View File

@@ -32,7 +32,7 @@ export const getEntitiesWithRoles = async (ids?: string[]): Promise<EntityWithRo
export const getEntities = async (ids?: string[], projection = {}) => { export const getEntities = async (ids?: string[], projection = {}) => {
return await db return await db
.collection("entities") .collection("entities")
.find<Entity>(ids ? { id: { $in: ids } } : {}, projection) .find<Entity>(ids ? { id: { $in: ids } } : {}, { projection })
.toArray(); .toArray();
}; };

View File

@@ -1,13 +1,13 @@
import { CEFR_STEPS } from "@/resources/grading"; import { CEFR_STEPS } from "@/resources/grading";
import { getUserCorporate } from "@/utils/groups.be";
import { User } from "@/interfaces/user";
import { Grading } from "@/interfaces"; import { Grading } from "@/interfaces";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export const getGradingSystemByEntity = async (id: string) => export const getGradingSystemByEntity = async (id: string, projection?: {}) =>
(await db.collection("grading").findOne<Grading>({ entity: id })) || { steps: CEFR_STEPS, entity: "" }; (await db.collection("grading").findOne<Grading>({ entity: id }, {
projection: projection,
})) || { steps: CEFR_STEPS, entity: "" };
export const getGradingSystemByEntities = async (ids: string[]) => export const getGradingSystemByEntities = async (ids: string[]) =>
await db.collection("grading").find<Grading>({ entity: { $in: ids } }).toArray(); await db.collection("grading").find<Grading>({ entity: { $in: ids } }).toArray();

View File

@@ -82,8 +82,8 @@ export const getGroups = async (): Promise<WithEntity<Group>[]> => {
.aggregate<WithEntity<Group>>(addEntityToGroupPipeline).toArray() .aggregate<WithEntity<Group>>(addEntityToGroupPipeline).toArray()
}; };
export const getParticipantGroups = async (id: string) => { export const getParticipantGroups = async (id: string, projection?: {}) => {
return await db.collection("groups").find<Group>({ participants: id }).toArray(); return await db.collection("groups").find<Group>({ participants: id }, { projection: projection }).toArray();
}; };
export const getParticipantsGroups = async (ids: string[]) => { export const getParticipantsGroups = async (ids: string[]) => {

View File

@@ -36,6 +36,43 @@ export function checkAccess(user: User, types: Type[], permissions?: PermissionT
return true; return true;
} }
export function groupAllowedEntitiesByPermissions(
user: User,
entities: EntityWithRoles[],
permissions: RolePermission[]
): { [key: string]: EntityWithRoles[] } {
if (["admin", "developer"].includes(user?.type)) {
return permissions.reduce((acc, permission) => {
acc[permission] = entities;
return acc;
}, {} as { [key: string]: EntityWithRoles[] });
}
const userEntityMap = new Map(user.entities.map(e => [e.id, e]));
const roleCache = new Map<string, Role | null>();
return entities.reduce((acc, entity) => {
const userEntity = userEntityMap.get(entity.id);
const role = userEntity
? roleCache.get(userEntity.role) ??
(() => {
const foundRole = entity.roles.find(r => r.id === userEntity.role) || null;
roleCache.set(userEntity.role, foundRole);
return foundRole;
})()
: null;
permissions.forEach(permission => {
if (!acc[permission]) acc[permission] = [];
if (role && role.permissions.includes(permission)) {
acc[permission].push(entity);
}
});
return acc;
}, {} as { [key: string]: EntityWithRoles[] });
}
export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) { export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) {
if (["admin", "developer"].includes(user?.type)) return entities if (["admin", "developer"].includes(user?.type)) return entities
@@ -52,7 +89,6 @@ export function findAllowedEntitiesSomePermissions(user: User, entities: EntityW
export function doesEntityAllow(user: User, entity: EntityWithRoles, permission: RolePermission) { export function doesEntityAllow(user: User, entity: EntityWithRoles, permission: RolePermission) {
if (isAdmin(user)) return true if (isAdmin(user)) return true
const userEntity = findBy(user.entities, 'id', entity?.id) const userEntity = findBy(user.entities, 'id', entity?.id)
if (!userEntity) return false if (!userEntity) return false

View File

@@ -10,3 +10,131 @@ export const getStatsByUsers = async (ids: string[]) =>
.collection("stats") .collection("stats")
.find<Stat>({ user: { $in: ids } }) .find<Stat>({ user: { $in: ids } })
.toArray(); .toArray();
export const getDetailedStatsByUser = async (id: string, query?: string) => {
let aggregateArray: any[] = [
{ $match: { user: id } },
{ $sort: { "date": 1 } },
]
switch (query) {
case "stats":
{
aggregateArray = aggregateArray.concat([{
$group: {
_id: "$session",
modules: { $addToSet: "$module" },
documents: { $push: "$$ROOT" },
totalCorrect: { $sum: "$score.correct" },
totalQuestions: { $sum: "$score.total" }
}
},
{
$project: {
_id: 0,
hasAllModules: {
$eq: [
{ $size: { $setIntersection: ["$modules", ["reading", "listening", "writing", "speaking"]] } },
4
]
},
uniqueModulesCount: { $size: "$modules" },
averageScore: {
$cond: [
{ $gt: ["$totalQuestions", 0] },
{ $multiply: [{ $divide: ["$totalCorrect", "$totalQuestions"] }, 100] },
0
]
},
documents: 1
}
},
{
$group: {
_id: null,
fullExams: { $sum: { $cond: ["$hasAllModules", 1, 0] } },
uniqueModules: { $sum: "$uniqueModulesCount" },
averageScore: {
$avg: "$averageScore"
},
allStats: { $push: "$documents" }
}
},
{
$project: {
_id: 0,
fullExams: 1,
uniqueModules: 1,
averageScore: 1,
allStats: {
$reduce: {
input: "$allStats",
initialValue: [],
in: { $concatArrays: ["$$value", "$$this"] }
}
}
}
}])
}
break;
case "byModule": {
aggregateArray = aggregateArray.concat([{
$facet: {
moduleCounts: [
{
$group: {
_id: {
module: "$module",
session: "$session"
}
}
},
{
$group: {
_id: "$_id.module",
count: {
$count: {}
}
}
},
{
$project: {
_id: 0,
module: "$_id",
count: "$count"
}
}
],
allDocuments: [
{
$project: {
_id: 0
}
}
]
}
},
{
$project: {
moduleCount: {
$arrayToObject: {
$map: {
input: "$moduleCounts",
as: "module",
in: {
k: "$$module.module",
v: "$$module.count"
}
}
}
},
allDocs: "$allDocuments"
}
}])
}
default:
}
return await db.collection("stats").aggregate(aggregateArray).toArray().then((result) => query ? result[0] : result
);
}

View File

@@ -6,7 +6,7 @@ import client from "@/lib/mongodb";
import { EntityWithRoles, WithEntities } from "@/interfaces/entity"; import { EntityWithRoles, WithEntities } from "@/interfaces/entity";
import { getEntity } from "./entities.be"; import { getEntity } from "./entities.be";
import { getRole } from "./roles.be"; import { getRole } from "./roles.be";
import { findAllowedEntities } from "./permissions"; import { findAllowedEntities, groupAllowedEntitiesByPermissions } from "./permissions";
import { mapBy } from "."; import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -75,6 +75,39 @@ export async function countUsers(filter?: object) {
.countDocuments(filter || {}) .countDocuments(filter || {})
} }
export async function countUsersByTypes(types: Type[]) {
return await db
.collection("users")
.aggregate([
{
$match: {
type: { $in: types } // Filter only specified types
}
},
{
$group: {
_id: "$type",
count: { $sum: 1 } // Count documents in each group
}
},
{
$group: {
_id: null,
counts: {
$push: { k: "$_id", v: "$count" } // Convert to key-value pairs
}
}
},
{
$project: {
_id: 0,
result: { $arrayToObject: "$counts" } // Convert key-value pairs to an object
}
}
]).toArray().then(([{ result }]) => result);
}
export async function getUserWithEntity(id: string): Promise<WithEntities<User> | undefined> { export async function getUserWithEntity(id: string): Promise<WithEntities<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 } });
if (!user) return undefined; if (!user) return undefined;
@@ -91,8 +124,8 @@ export async function getUserWithEntity(id: string): Promise<WithEntities<User>
return { ...user, entities }; return { ...user, entities };
} }
export async function getUser(id: string): Promise<User | undefined> { export async function getUser(id: string, projection = {}): 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, ...projection } });
return !!user ? user : undefined; return !!user ? user : undefined;
} }
@@ -120,7 +153,7 @@ export async function countEntityUsers(id: string, filter?: object) {
export async function getEntitiesUsers(ids: string[], filter?: object, limit?: number, projection = {}) { export async function getEntitiesUsers(ids: string[], filter?: object, limit?: number, projection = {}) {
return await db return await db
.collection("users") .collection("users")
.find<User>({ "entities.id": { $in: ids }, ...(filter || {}) }, projection) .find<User>({ "entities.id": { $in: ids }, ...(filter || {}) }, { projection })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
} }
@@ -199,29 +232,45 @@ export async function getUserBalance(user: User) {
} }
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => { export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => {
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
const teachersAllowedEntities = findAllowedEntities(user, entities, 'view_teachers')
const corporateAllowedEntities = findAllowedEntities(user, entities, 'view_corporates')
const masterCorporateAllowedEntities = findAllowedEntities(user, entities, 'view_mastercorporates')
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" }) const {
const teachers = await getEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" }) ["view_students"]: allowedStudentEntities,
const corporates = await getEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" }) ["view_teachers"]: allowedTeacherEntities,
const masterCorporates = await getEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" }) ["view_corporates"]: allowedCorporateEntities,
["view_mastercorporates"]: allowedMasterCorporateEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
'view_corporates',
'view_mastercorporates',
]);
const students = await getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
const teachers = await getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
const corporates = await getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
const masterCorporates = await getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
return [...students, ...teachers, ...corporates, ...masterCorporates] return [...students, ...teachers, ...corporates, ...masterCorporates]
} }
export const countAllowedUsers = async (user: User, entities: EntityWithRoles[]) => { export const countAllowedUsers = async (user: User, entities: EntityWithRoles[]) => {
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students') const {
const teachersAllowedEntities = findAllowedEntities(user, entities, 'view_teachers') ["view_students"]: allowedStudentEntities,
const corporateAllowedEntities = findAllowedEntities(user, entities, 'view_corporates') ["view_teachers"]: allowedTeacherEntities,
const masterCorporateAllowedEntities = findAllowedEntities(user, entities, 'view_mastercorporates') ["view_corporates"]: allowedCorporateEntities,
["view_mastercorporates"]: allowedMasterCorporateEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
'view_corporates',
'view_mastercorporates',
]);
const student = await countEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" }) const student = await countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
const teacher = await countEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" }) const teacher = await countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
const corporate = await countEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" }) const corporate = await countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
const mastercorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" }) const mastercorporate = await countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
return { student, teacher, corporate, mastercorporate } return { student, teacher, corporate, mastercorporate }
} }