Compare commits

...

12 Commits

Author SHA1 Message Date
Francisco Lima
58aebaa66c Merged in ENCOA-316-ENCOA-317 (pull request #141)
Fix login page having a Card

Approved-by: Tiago Ribeiro
2025-01-29 08:59:55 +00:00
José Marques Lima
b69b6e6c77 Fix login page having a Card 2025-01-28 20:31:19 +00:00
Francisco Lima
86af876f01 Merged in ENCOA-316-ENCOA-317 (pull request #140)
Fix entities Page not rendering

Approved-by: Tiago Ribeiro
2025-01-28 09:35:02 +00:00
Tiago Ribeiro
b685259dc7 Merged develop into ENCOA-316-ENCOA-317 2025-01-28 09:30:34 +00:00
José Marques Lima
16b959fb7a Fix entities Page not rendering 2025-01-27 22:06:34 +00:00
Francisco Lima
db95fc5681 Merged in ENCOA-316-ENCOA-317 (pull request #139)
ENCOA-316 ENCOA-317

Approved-by: Tiago Ribeiro
2025-01-27 09:27:09 +00:00
José Marques Lima
c98af863c3 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-316-ENCOA-317 2025-01-25 20:01:52 +00:00
José Marques Lima
37216e2a5a ENCOA-316 ENCOA-317:
Refactor components to remove Layout wrapper and pass it in the App component , implemented a skeleton feedback while loading page and improved API calls related to Dashboard/User Profile
2025-01-25 19:38:29 +00:00
carlos.mesquita
f727ab4792 Merged in feature/ExamGenRework (pull request #138)
ENCOA-315

Approved-by: Tiago Ribeiro
2025-01-22 08:27:46 +00:00
Carlos-Mesquita
1c75a0e59c ENCOA-315: Small fix and merge 2025-01-22 05:24:49 +00:00
Carlos-Mesquita
e36b24ea3f ENCOA-315 2025-01-22 04:46:24 +00:00
Francisco Lima
4d788e13b4 Merged in ENCOA-314 (pull request #137)
ENCOA-314

Approved-by: Tiago Ribeiro
2025-01-20 17:15:33 +00:00
66 changed files with 4815 additions and 3158 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,66 +5,126 @@ 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,
children, entities,
className, children,
bgColor = "bg-white", refreshPage,
hideSidebar,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter
}: Props) { }: Props) {
const router = useRouter(); const [onFocusLayerMouseEnter, setOnFocusLayerMouseEnter] = useState(
const { entities } = useEntities() () => () => {}
);
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("");
return ( useEffect(() => {
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}> if (refreshPage) {
<ToastContainer /> setClassName("");
{!hideSidebar && user && ( setBgColor("bg-white");
<Navbar setFocusMode(false);
path={router.pathname} setHideSidebar(false);
user={user} setNavDisabled(false);
navDisabled={navDisabled} setOnFocusLayerMouseEnter(() => () => {});
focusMode={focusMode} }
onFocusLayerMouseEnter={onFocusLayerMouseEnter} }, [refreshPage]);
/>
)} const LayoutContextValue = React.useMemo(
<div className={clsx("h-full w-full flex gap-2")}> () => ({
{!hideSidebar && user && ( onFocusLayerMouseEnter,
<Sidebar setOnFocusLayerMouseEnter,
path={router.pathname} navDisabled,
navDisabled={navDisabled} setNavDisabled,
focusMode={focusMode} focusMode,
onFocusLayerMouseEnter={onFocusLayerMouseEnter} setFocusMode,
className="-md:hidden" hideSidebar,
user={user} setHideSidebar,
entities={entities} bgColor,
/> setBgColor,
)} className,
<div setClassName,
className={clsx( }),
`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,
hideSidebar ? "md:mx-8" : "md:mr-8", className,
className, focusMode,
)}> hideSidebar,
{children} navDisabled,
</div> onFocusLayerMouseEnter,
</div> ]
</main> );
);
const router = useRouter();
return (
<LayoutContext.Provider value={LayoutContextValue}>
<main
className={clsx(
"w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative"
)}
>
<ToastContainer />
{!hideSidebar && user && (
<Navbar
path={router.pathname}
user={user}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
/>
)}
<div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && user && (
<Sidebar
path={router.pathname}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden"
user={user}
entities={entities}
/>
)}
<div
className={clsx(
`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",
hideSidebar ? "md:mx-8" : "md:mr-8",
className
)}
>
{children}
</div>
</div>
</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

@@ -2,17 +2,16 @@ import clsx from "clsx";
import { IconType } from "react-icons"; import { IconType } from "react-icons";
import { MdSpaceDashboard } from "react-icons/md"; import { MdSpaceDashboard } from "react-icons/md";
import { import {
BsFileEarmarkText, BsFileEarmarkText,
BsClockHistory, BsClockHistory,
BsPencil, BsGraphUp,
BsGraphUp, BsChevronBarRight,
BsChevronBarRight, BsChevronBarLeft,
BsChevronBarLeft, BsShieldFill,
BsShieldFill, BsCloudFill,
BsCloudFill, BsCurrencyDollar,
BsCurrencyDollar, BsClipboardData,
BsClipboardData, BsPeople,
BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import { CiDumbbell } from "react-icons/ci"; import { CiDumbbell } from "react-icons/ci";
import { RiLogoutBoxFill } from "react-icons/ri"; import { RiLogoutBoxFill } from "react-icons/ri";
@@ -24,218 +23,454 @@ 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;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
user: User; user: User;
entities?: EntityWithRoles[] entities?: EntityWithRoles[];
} }
interface NavProps { interface NavProps {
Icon: IconType; Icon: IconType;
label: string; label: string;
path: string; path: string;
keyPath: string; keyPath: string;
disabled?: boolean; disabled?: boolean;
isMinimized?: boolean; isMinimized?: boolean;
badge?: number; badge?: number;
} }
const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => { const Nav = ({
return ( Icon,
<Link label,
href={!disabled ? keyPath : ""} path,
className={clsx( keyPath,
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", disabled = false,
"transition-all duration-300 ease-in-out relative", isMinimized = false,
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer", badge,
path.startsWith(keyPath) && "bg-mti-purple-light text-white", }: NavProps) => {
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", return (
)}> <Link
<Icon size={24} /> href={!disabled ? keyPath : ""}
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} className={clsx(
{!!badge && badge > 0 && ( "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
<div "transition-all duration-300 ease-in-out relative",
className={clsx( disabled
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white", ? "hover:bg-mti-gray-dim cursor-not-allowed"
"transition ease-in-out duration-300", : "hover:bg-mti-purple-light cursor-pointer",
isMinimized && "absolute right-0 top-0", path.startsWith(keyPath) && "bg-mti-purple-light text-white",
)}> isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]"
{badge} )}
</div> >
)} <Icon size={24} />
</Link> {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
); {!!badge && badge > 0 && (
<div
className={clsx(
"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",
isMinimized && "absolute right-0 top-0"
)}
>
{badge}
</div>
)}
</Link>
);
}; };
export default function Sidebar({ export default function Sidebar({
path, path,
entities = [], entities = [],
navDisabled = false, navDisabled = false,
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 logout = async () => { const sidebarPermissions = useMemo<{ [key: string]: boolean }>(() => {
axios.post("/api/logout").finally(() => { if (user.type === "developer") {
setTimeout(() => router.reload(), 500); 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,
};
const disableNavigation = preventNavigation(navDisabled, focusMode); if (!user || !user?.type) return sidebarPermissions;
return ( const neededPermissions = permissions.reduce((acc, curr) => {
<section if (
className={clsx( ["viewExams", "viewRecords", "viewTickets"].includes(curr as string)
"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", acc.push(curr);
className, }
)}> return acc;
<div className="-xl:hidden flex-col gap-3 xl:flex"> }, [] as PermissionType[]);
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/dashboard" isMinimized={isMinimized} />
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Practice" path={path} keyPath="/exam" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"])) && entitiesAllowStatistics.length > 0 && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)}
{checkAccess(user, ["developer", "admin", "mastercorporate", "corporate", "teacher", "student"], permissions) && (
<Nav
disabled={disableNavigation}
Icon={BsPeople}
label="Classrooms"
path={path}
keyPath="/classrooms"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
)}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"]) && entitiesAllowPaymentRecord.length > 0 && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{checkAccess(user, ["admin", "developer", "teacher", 'corporate', 'mastercorporate'])
&& (entitiesAllowGeneration.length > 0 || isAdmin) && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized />
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized />
)}
{entitiesAllowGeneration.length > 0 && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized
/>
)}
</div>
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8"> if (
<div ["student", "teacher", "developer"].includes(user.type) &&
role="button" neededPermissions.includes("viewExams")
tabIndex={1} ) {
onClick={toggleMinimize} sidebarPermissions["viewExams"] = true;
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", if (
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", getTypesOfUser(["agent"]).includes(user.type) &&
)}> (entitiesAllowStatistics.length > 0 ||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />} neededPermissions.includes("viewStats"))
{!isMinimized && <span className="text-lg font-medium">Minimize</span>} ) {
</div> sidebarPermissions["viewStats"] = true;
<div }
role="button" if (
tabIndex={1} [
onClick={focusMode ? () => { } : logout} "admin",
className={clsx( "developer",
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "teacher",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", "corporate",
)}> "mastercorporate",
<RiLogoutBoxFill size={24} /> ].includes(user.type) &&
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>} (entitiesAllowGeneration.length > 0 || isAdmin)
</div> ) {
</div> sidebarPermissions["viewGeneration"] = true;
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} }
</section> 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 () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
const disableNavigation = preventNavigation(navDisabled, focusMode);
return (
<section
className={clsx(
"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",
className
)}
>
<div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav
disabled={disableNavigation}
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}
/>
)}
{sidebarPermissions["viewStats"] && (
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={isMinimized}
/>
)}
{sidebarPermissions["viewClassrooms"] && (
<Nav
disabled={disableNavigation}
Icon={BsPeople}
label="Classrooms"
path={path}
keyPath="/classrooms"
isMinimized={isMinimized}
/>
)}
{sidebarPermissions["viewRecords"] && (
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={isMinimized}
/>
)}
{sidebarPermissions["viewRecords"] && (
<Nav
disabled={disableNavigation}
Icon={CiDumbbell}
label="Training"
path={path}
keyPath="/training"
isMinimized={isMinimized}
/>
)}
{sidebarPermissions["viewPaymentRecords"] && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{sidebarPermissions["viewSettings"] && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{sidebarPermissions["viewTickets"] && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{sidebarPermissions["viewGeneration"] && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav
disabled={disableNavigation}
Icon={MdSpaceDashboard}
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
/>
)}
{sidebarPermissions["viewRecords"] && (
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized
/>
)}
{sidebarPermissions["viewRecords"] && (
<Nav
disabled={disableNavigation}
Icon={CiDumbbell}
label="Training"
path={path}
keyPath="/training"
isMinimized
/>
)}
{sidebarPermissions["viewSettings"] && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized
/>
)}
{sidebarPermissions["viewGeneration"] && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized
/>
)}
</div>
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
<div
role="button"
tabIndex={1}
onClick={toggleMinimize}
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",
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>
)}
</div>
<div
role="button"
tabIndex={1}
onClick={focusMode ? () => {} : logout}
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",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)}
>
<RiLogoutBoxFill size={24} />
{!isMinimized && (
<span className="-xl:hidden text-lg font-medium">Log Out</span>
)}
</div>
</div>
{focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</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 };
} }

View File

@@ -1,95 +1,114 @@
import { UserSolution } from '@/interfaces/exam'; import { UserSolution } from "@/interfaces/exam";
import useExamStore from '@/stores/exam'; import useExamStore from "@/stores/exam";
import { StateFlags } from '@/stores/exam/types'; import axios from "axios";
import axios from 'axios'; import { useEffect, useRef } from "react";
import { SetStateAction, useEffect, useRef } from 'react'; import { useRouter } from "next/router";
type UseEvaluationPolling = (props: { const useEvaluationPolling = (sessionIds: string[], mode: "exam" | "records", userId: string) => {
pendingExercises: string[], const { setUserSolutions, userSolutions } = useExamStore();
setPendingExercises: React.Dispatch<SetStateAction<string[]>>, const pollingTimeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
}) => void; const router = useRouter();
const useEvaluationPolling: UseEvaluationPolling = ({ const poll = async (sessionId: string) => {
pendingExercises, try {
setPendingExercises, const { data: statusData } = await axios.get('/api/evaluate/status', {
}) => { params: { op: 'pending', userId, sessionId }
const { });
flags, sessionId, user,
userSolutions, evaluated,
setEvaluated, setFlags
} = useExamStore();
const pollingTimeoutRef = useRef<NodeJS.Timeout>(); if (!statusData.hasPendingEvaluation) {
useEffect(() => { let solutionsOrStats = userSolutions;
return () => {
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
}
};
}, []);
useEffect(() => { if (mode === "records") {
if (!flags.pendingEvaluation || pendingExercises.length === 0) { const res = await axios.get(`/api/stats/session/${sessionId}`)
solutionsOrStats = res.data;
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
}
return;
} }
const { data: completedSolutions } = await axios.post('/api/evaluate/fetchSolutions?op=session', {
sessionId,
userId,
stats: solutionsOrStats,
});
const pollStatus = async () => { await axios.post('/api/stats/disabled', {
try { sessionId,
const { data } = await axios.get('/api/evaluate/status', { userId,
params: { solutions: completedSolutions,
sessionId, });
userId: user,
exerciseIds: pendingExercises.join(',')
}
});
if (data.finishedExerciseIds.length > 0) { const timeout = pollingTimeoutsRef.current.get(sessionId);
const remainingExercises = pendingExercises.filter( if (timeout) clearTimeout(timeout);
id => !data.finishedExerciseIds.includes(id) pollingTimeoutsRef.current.delete(sessionId);
);
setPendingExercises(remainingExercises); if (mode === "exam") {
const updatedSolutions = userSolutions.map(solution => {
const completed = completedSolutions.find(
(c: UserSolution) => c.exercise === solution.exercise
);
return completed || solution;
});
if (remainingExercises.length === 0) { setUserSolutions(updatedSolutions);
const evaluatedData = await axios.post('/api/evaluate/fetchSolutions', { } else {
sessionId, router.reload();
userId: user, }
userSolutions } else {
}); if (pollingTimeoutsRef.current.has(sessionId)) {
clearTimeout(pollingTimeoutsRef.current.get(sessionId));
}
pollingTimeoutsRef.current.set(
sessionId,
setTimeout(() => poll(sessionId), 5000)
);
}
} catch (error) {
if (pollingTimeoutsRef.current.has(sessionId)) {
clearTimeout(pollingTimeoutsRef.current.get(sessionId));
}
pollingTimeoutsRef.current.set(
sessionId,
setTimeout(() => poll(sessionId), 5000)
);
}
};
const newEvaluations = evaluatedData.data.filter( useEffect(() => {
(newEval: UserSolution) => if (mode === "exam") {
!evaluated.some(existingEval => existingEval.exercise === newEval.exercise) const hasDisabledSolutions = userSolutions.some(s => s.isDisabled);
);
setEvaluated([...evaluated, ...newEvaluations]); if (hasDisabledSolutions && sessionIds.length > 0) {
setFlags({ pendingEvaluation: false }); poll(sessionIds[0]);
return; } else {
} pollingTimeoutsRef.current.forEach((timeout) => {
} clearTimeout(timeout);
});
pollingTimeoutsRef.current.clear();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, sessionIds, userSolutions]);
if (pendingExercises.length > 0) { useEffect(() => {
pollingTimeoutRef.current = setTimeout(pollStatus, 5000); if (mode === "records" && sessionIds.length > 0) {
} sessionIds.forEach(sessionId => {
} catch (error) { poll(sessionId);
console.error('Evaluation polling error:', error); });
pollingTimeoutRef.current = setTimeout(pollStatus, 5000); }
} // eslint-disable-next-line react-hooks/exhaustive-deps
}; }, [mode, sessionIds]);
pollStatus(); useEffect(() => {
const timeouts = pollingTimeoutsRef.current;
return () => {
timeouts.forEach((timeout) => {
clearTimeout(timeout);
});
timeouts.clear();
};
}, []);
return () => { return {
if (pollingTimeoutRef.current) { isPolling: pollingTimeoutsRef.current.size > 0
clearTimeout(pollingTimeoutRef.current); };
}
};
});
}; };
export default useEvaluationPolling; export default useEvaluationPolling;

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";
@@ -24,317 +27,436 @@ import useExamStore from "@/stores/exam";
import useEvaluationPolling from "@/hooks/useEvaluationPolling"; 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({
const router = useRouter(); page,
const [variant, setVariant] = useState<Variant>("full"); user,
const [avoidRepeated, setAvoidRepeated] = useState(false); destination = "/",
const [showAbandonPopup, setShowAbandonPopup] = useState(false); hideSidebar = false,
const [pendingExercises, setPendingExercises] = useState<string[]>([]); }: Props) {
const router = useRouter();
const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [moduleLock, setModuleLock] = useState(false);
const { const {
exam, setExam, exam,
exams, setExam,
sessionId, setSessionId, setPartIndex, exams,
moduleIndex, setModuleIndex, sessionId,
setQuestionIndex, setExerciseIndex, setSessionId,
userSolutions, setUserSolutions, setPartIndex,
showSolutions, setShowSolutions, moduleIndex,
selectedModules, setSelectedModules, setModuleIndex,
setUser, setQuestionIndex,
inactivity, setExerciseIndex,
timeSpent, userSolutions,
assignment, setUserSolutions,
bgColor, showSolutions,
flags, setShowSolutions,
dispatch, selectedModules,
reset: resetStore, setSelectedModules,
saveStats, setUser,
saveSession, inactivity,
setFlags, timeSpent,
setShuffles, assignment,
evaluated, bgColor,
} = useExamStore(); flags,
dispatch,
reset: resetStore,
saveStats,
saveSession,
setFlags,
setShuffles,
} = 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);
}, [showSolutions, moduleIndex, selectedModules]); }, [showSolutions, moduleIndex, selectedModules]);
useEffect(() => { useEffect(() => {
if (!showSolutions && sessionId.length === 0 && user?.id) { if (!showSolutions && sessionId.length === 0 && user?.id) {
const shortUID = new ShortUniqueId(); const shortUID = new ShortUniqueId();
setUser(user.id); setUser(user.id);
setSessionId(shortUID.randomUUID(8)); setSessionId(shortUID.randomUUID(8));
} }
}, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]); }, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]);
useEffect(() => { useEffect(() => {
if (user?.type === "developer") console.log(exam); if (user?.type === "developer") console.log(exam);
}, [exam, user]); }, [exam, user]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length === 0) { if (selectedModules.length > 0 && exams.length === 0) {
setIsFetchingExams(true); setIsFetchingExams(true);
const examPromises = selectedModules.map((module) => const examPromises = selectedModules.map((module) =>
getExam( getExam(
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) => { )
setIsFetchingExams(false); );
if (values.every((x) => !!x)) { Promise.all(examPromises).then((values) => {
dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } }) setIsFetchingExams(false);
} else { if (values.every((x) => !!x)) {
toast.error("Something went wrong, please try again"); dispatch({
setTimeout(router.reload, 500); type: "INIT_EXAM",
} payload: {
}); exams: values.map((x) => x!),
} modules: selectedModules,
})(); },
// eslint-disable-next-line react-hooks/exhaustive-deps });
}, [selectedModules, exams]); } else {
toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500);
}
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, exams]);
const reset = () => {
resetStore();
setVariant("full");
setAvoidRepeated(false);
setShowAbandonPopup(false);
};
const reset = () => { useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
resetStore();
setVariant("full");
setAvoidRepeated(false);
setShowAbandonPopup(false);
};
useEffect(() => { useEffect(() => {
if (flags.finalizeModule && !showSolutions && flags.pendingEvaluation) { setModuleLock(true);
if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) { }, [flags.finalizeModule]);
const exercisesToEvaluate = exam.exercises
.map(exercise => exercise.id);
setPendingExercises(exercisesToEvaluate); useEffect(() => {
(async () => { if (flags.finalizeModule && !showSolutions) {
await Promise.all( if (
exam.exercises.map(async (exercise, index) => { exam &&
if (exercise.type === "writing") (exam.module === "writing" || exam.module === "speaking") &&
await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url); userSolutions.length > 0
) {
(async () => {
try {
const results = await Promise.all(
exam.exercises.map(async (exercise, index) => {
if (exercise.type === "writing") {
const sol = await evaluateWritingAnswer(
user.id,
sessionId,
exercise,
index + 1,
userSolutions.find((x) => x.exercise === exercise.id)!,
exercise.attachment?.url
);
return sol;
}
if (
exercise.type === "interactiveSpeaking" ||
exercise.type === "speaking"
) {
const sol = await evaluateSpeakingAnswer(
user.id,
sessionId,
exercise,
userSolutions.find((x) => x.exercise === exercise.id)!,
index + 1
);
return sol;
}
return null;
})
);
const updatedSolutions = userSolutions.map((solution) => {
const completed = results
.filter((r) => r !== null)
.find((c: any) => c.exercise === solution.exercise);
return completed || solution;
});
setUserSolutions(updatedSolutions);
} catch (error) {
console.error("Error during module evaluation:", error);
} finally {
setModuleLock(false);
}
})();
} else {
setModuleLock(false);
}
}
}, [
exam,
showSolutions,
userSolutions,
sessionId,
user.id,
flags.finalizeModule,
setUserSolutions,
]);
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") { useEffect(() => {
await evaluateSpeakingAnswer( if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) {
user.id, (async () => {
sessionId, setModuleIndex(-1);
exercise, await saveStats();
userSolutions.find((x) => x.exercise === exercise.id)!, await axios.get("/api/stats/update");
index + 1, })();
); }
} }, [
}), flags.finalizeExam,
) moduleIndex,
})(); saveStats,
} setModuleIndex,
} userSolutions,
}, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]); moduleLock,
flags.finalizeModule,
]);
useEvaluationPolling({ pendingExercises, setPendingExercises }); useEffect(() => {
if (
flags.finalizeExam &&
!userSolutions.some((s) => s.isDisabled) &&
!moduleLock
) {
setShowSolutions(true);
setFlags({ finalizeExam: false });
dispatch({ type: "UPDATE_EXAMS" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]);
useEffect(() => { const aggregateScoresByModule = (
if (flags.finalizeExam && moduleIndex !== -1) { isPractice?: boolean
setModuleIndex(-1); ): {
module: Module;
total: number;
missing: number;
correct: number;
}[] => {
const scores: {
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
userSolutions.forEach((x) => {
if (x.isPractice === isPractice) {
const examModule =
x.module ||
(x.type === "writing"
? "writing"
: x.type === "speaking" || x.type === "interactiveSpeaking"
? "speaking"
: undefined);
} scores[examModule!] = {
}, [flags.finalizeExam, moduleIndex, setModuleIndex]); total: scores[examModule!].total + x.score.total,
correct: scores[examModule!].correct + x.score.correct,
missing: scores[examModule!].missing + x.score.missing,
};
}
});
useEffect(() => { return Object.keys(scores).reduce<
if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) { { module: Module; total: number; missing: number; correct: number }[]
(async () => { >((accm, x) => {
if (evaluated.length !== 0) { if (scores[x as Module].total > 0)
setUserSolutions( accm.push({ module: x as Module, ...scores[x as Module] });
userSolutions.map(solution => { return accm;
const evaluatedSolution = evaluated.find(e => e.exercise === solution.exercise); }, []);
if (evaluatedSolution) { };
return { ...solution, ...evaluatedSolution };
}
return solution;
})
);
}
await saveStats();
await axios.get("/api/stats/update");
setShowSolutions(true);
setFlags({ finalizeExam: false });
dispatch({ type: "UPDATE_EXAMS" })
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveStats, setFlags, setModuleIndex, evaluated, pendingExercises, setUserSolutions, flags]);
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
reading: Reading as React.ComponentType<ExamProps<Exam>>,
listening: Listening as React.ComponentType<ExamProps<Exam>>,
writing: Writing as React.ComponentType<ExamProps<Exam>>,
speaking: Speaking as React.ComponentType<ExamProps<Exam>>,
level: Level as React.ComponentType<ExamProps<Exam>>,
};
const aggregateScoresByModule = (isPractice?: boolean): { const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
module: Module;
total: number;
missing: number;
correct: number;
}[] => {
const scores: {
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
userSolutions.filter(x => isPractice ? x.isPractice : !x.isPractice).forEach((x) => { const onAbandon = async () => {
const examModule = await saveSession();
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined); reset();
};
scores[examModule!] = { const {
total: scores[examModule!].total + x.score.total, setBgColor,
correct: scores[examModule!].correct + x.score.correct, setHideSidebar,
missing: scores[examModule!].missing + x.score.missing, setFocusMode,
}; setOnFocusLayerMouseEnter,
}); } = React.useContext(LayoutContext);
return Object.keys(scores) useEffect(() => {
.filter((x) => scores[x as Module].total > 0) setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
.map((x) => ({ module: x as Module, ...scores[x as Module] })); // eslint-disable-next-line react-hooks/exhaustive-deps
}; }, []);
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = { useEffect(() => {
"reading": Reading as React.ComponentType<ExamProps<Exam>>, setBgColor(bgColor);
"listening": Listening as React.ComponentType<ExamProps<Exam>>, setHideSidebar(hideSidebar);
"writing": Writing as React.ComponentType<ExamProps<Exam>>, setFocusMode(
"speaking": Speaking as React.ComponentType<ExamProps<Exam>>, selectedModules.length !== 0 &&
"level": Level as React.ComponentType<ExamProps<Exam>>, !showSolutions &&
} moduleIndex < selectedModules.length
);
}, [
bgColor,
hideSidebar,
moduleIndex,
selectedModules.length,
setBgColor,
setFocusMode,
setHideSidebar,
showSolutions,
]);
const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined; return (
<>
const onAbandon = async () => { <ToastContainer />
await saveSession(); {user && (
reset(); <>
}; {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
{selectedModules.length === 0 && (
return ( <Selection
<> page={page}
<ToastContainer /> user={user!}
{user && ( onStart={(
<Layout modules: Module[],
user={user} avoid: boolean,
bgColor={bgColor} variant: Variant
hideSidebar={hideSidebar} ) => {
className="justify-between" setModuleIndex(0);
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length} setAvoidRepeated(avoid);
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}> setSelectedModules(modules);
<> setVariant(variant);
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} }}
{selectedModules.length === 0 && <Selection />
page={page} )}
user={user!} {isFetchingExams && (
onStart={(modules: Module[], avoid: boolean, variant: Variant) => { <div className="flex flex-grow flex-col items-center justify-center animate-pulse">
setModuleIndex(0); <span
setAvoidRepeated(avoid); className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`}
setSelectedModules(modules); />
setVariant(variant); <span
}} className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
/>} >
{isFetchingExams && ( Loading Exam ...
<div className="flex flex-grow flex-col items-center justify-center animate-pulse"> </span>
<span className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`} /> </div>
<span className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}>Loading Exam ...</span> )}
</div> {moduleIndex === -1 && selectedModules.length !== 0 && (
)} <Finish
{(moduleIndex === -1 && selectedModules.length !== 0) && isLoading={userSolutions.some((s) => s.isDisabled)}
<Finish user={user!}
isLoading={flags.pendingEvaluation} modules={selectedModules}
user={user!} solutions={userSolutions}
modules={selectedModules} assignment={assignment}
solutions={userSolutions} information={{
assignment={assignment} timeSpent,
information={{ inactivity,
timeSpent, }}
inactivity, destination={destination}
}} onViewResults={(index?: number) => {
destination={destination} if (exams[0].module === "level") {
onViewResults={(index?: number) => { const levelExam = exams[0] as LevelExam;
if (exams[0].module === "level") { const allExercises = levelExam.parts.flatMap(
const levelExam = exams[0] as LevelExam; (part) => part.exercises
const allExercises = levelExam.parts.flatMap((part) => part.exercises); );
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index])); const exerciseOrderMap = new Map(
const orderedSolutions = userSolutions.slice().sort((a, b) => { allExercises.map((ex, index) => [ex.id, index])
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity; );
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity; const orderedSolutions = userSolutions
return indexA - indexB; .slice()
}); .sort((a, b) => {
setUserSolutions(orderedSolutions); const indexA =
} else { exerciseOrderMap.get(a.exercise) ?? Infinity;
setUserSolutions(userSolutions); const indexB =
} exerciseOrderMap.get(b.exercise) ?? Infinity;
setShuffles([]); return indexA - indexB;
if (index === undefined) { });
setFlags({ reviewAll: true }); setUserSolutions(orderedSolutions);
setModuleIndex(0); } else {
setExam(exams[0]); setUserSolutions(userSolutions);
} else { }
setModuleIndex(index); setShuffles([]);
setExam(exams[index]); if (index === undefined) {
} setFlags({ reviewAll: true });
setShowSolutions(true); setModuleIndex(0);
setQuestionIndex(0); setExam(exams[0]);
setExerciseIndex(0); } else {
setPartIndex(0); setModuleIndex(index);
}} setExam(exams[index]);
scores={aggregateScoresByModule()} }
practiceScores={aggregateScoresByModule(true)} setShowSolutions(true);
/>} setQuestionIndex(0);
{/* Exam is on going, display it and the abandon modal */} setExerciseIndex(0);
{isExamLoaded && moduleIndex !== -1 && ( setPartIndex(0);
<> }}
{exam && CurrentExam && <CurrentExam exam={exam} showSolutions={showSolutions} />} scores={aggregateScoresByModule()}
{!showSolutions && <AbandonPopup practiceScores={aggregateScoresByModule(true)}
isOpen={showAbandonPopup} />
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." {/* Exam is on going, display it and the abandon modal */}
abandonConfirmButtonText="Confirm" {isExamLoaded && moduleIndex !== -1 && (
onAbandon={onAbandon} <>
onCancel={() => setShowAbandonPopup(false)} {exam && CurrentExam && (
/> <CurrentExam exam={exam} showSolutions={showSolutions} />
} )}
</> {!showSolutions && (
)} <AbandonPopup
</> isOpen={showAbandonPopup}
</Layout> 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."
</> abandonConfirmButtonText="Confirm"
); onAbandon={onAbandon}
onCancel={() => setShowAbandonPopup(false)}
/>
)}
</>
)}
</>
)}
</>
);
} }

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,241 +18,345 @@ 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({
const [isLoading, setIsLoading] = useState(false); user,
const [entity, setEntity] = useState<EntityWithRoles>() discounts = [],
entities = [],
packages = [],
hasExpired = false,
reload,
}: Props) {
const [isLoading, setIsLoading] = useState(false);
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 (
return 0; !biggestDiscount ||
(biggestDiscount.validUntil &&
moment(biggestDiscount.validUntil).isBefore(moment()))
)
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 (
<> <>
<ToastContainer /> <ToastContainer />
{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> />
<button <span className={clsx("text-2xl font-bold animate-pulse")}>
onClick={() => setIsLoading(false)} Completing your payment...
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"> </span>
Cancel Payment <span>
</button> If you canceled your payment or it failed, please click the button
</div> below to restart
</div> </span>
)} <button
<Layout user={user} navDisabled={hasExpired}> onClick={() => setIsLoading(false)}
{invites.length > 0 && ( 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"
<section className="flex flex-col gap-1 md:gap-3"> >
<div className="flex items-center gap-4"> Cancel Payment
<div </button>
onClick={reloadInvites} </div>
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> </div>
<span className="text-mti-black text-lg font-bold">Invites</span> )}
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} /> <>
</div> {invites.length > 0 && (
</div> <section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <div className="flex items-center gap-4">
{invites.map((invite) => ( <div
<InviteCard onClick={reloadInvites}
key={invite.id} className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
invite={invite} >
users={users} <span className="text-mti-black text-lg font-bold">
reload={() => { Invites
reloadInvites(); </span>
router.reload(); <BsArrowRepeat
}} className={clsx(
/> "text-xl",
))} isInvitesLoading && "animate-spin"
</span> )}
</section> />
)} </div>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => (
<InviteCard
key={invite.id}
invite={invite}
users={users}
reload={() => {
reloadInvites();
router.reload();
}}
/>
))}
</span>
</section>
)}
<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 && (
{isIndividual && ( <span className="text-lg font-bold">
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll"> You do not have time credits for your account type!
<span className="max-w-lg"> </span>
To add to your use of EnCoach, please purchase one of the time packages available below: )}
</span> {isIndividual && (
<div className="flex w-full flex-wrap justify-center gap-8"> <div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
{packages.map((p) => ( <span className="max-w-lg">
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> To add to your use of EnCoach, please purchase one of the time
<div className="mb-2 flex flex-col items-start"> packages available below:
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> </span>
<span className="text-xl font-semibold"> <div className="flex w-full flex-wrap justify-center gap-8">
EnCoach - {p.duration}{" "} {packages.map((p) => (
{capitalize( <div
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, key={p.id}
)} className={clsx(
</span> "flex flex-col items-start gap-6 rounded-xl bg-white p-4"
</div> )}
<div className="flex w-full flex-col items-start gap-2"> >
{appliedDiscount === 0 && ( <div className="mb-2 flex flex-col items-start">
<span className="text-2xl"> <img
{p.price} {p.currency} src="/logo_title.png"
</span> alt="EnCoach's Logo"
)} className="w-32"
{appliedDiscount > 0 && ( />
<div className="flex items-center gap-2"> <span className="text-xl font-semibold">
<span className="text-2xl line-through"> EnCoach - {p.duration}{" "}
{p.price} {p.currency} {capitalize(
</span> p.duration === 1
<span className="text-2xl text-mti-red-light"> ? p.duration_unit.slice(
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} 0,
</span> p.duration_unit.length - 1
</div> )
)} : p.duration_unit
<PaymobPayment )}
user={user} </span>
setIsPaymentLoading={setIsLoading} </div>
onSuccess={() => { <div className="flex w-full flex-col items-start gap-2">
setTimeout(reload, 500); {appliedDiscount === 0 && (
}} <span className="text-2xl">
currency={p.currency} {p.price} {p.currency}
duration={p.duration} </span>
duration_unit={p.duration_unit} )}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {appliedDiscount > 0 && (
/> <div className="flex items-center gap-2">
</div> <span className="text-2xl line-through">
<div className="flex flex-col items-start gap-1"> {p.price} {p.currency}
<span>This includes:</span> </span>
<ul className="flex flex-col items-start text-sm"> <span className="text-2xl text-mti-red-light">
<li>- Train your abilities for the IELTS exam</li> {(
<li>- Gain insights into your weaknesses and strengths</li> p.price -
<li>- Allow yourself to correctly prepare for the exam</li> p.price * (appliedDiscount / 100)
</ul> ).toFixed(2)}{" "}
</div> {p.currency}
</div> </span>
))} </div>
</div> )}
</div> <PaymobPayment
)} user={user}
setIsPaymentLoading={setIsLoading}
onSuccess={() => {
setTimeout(reload, 500);
}}
currency={p.currency}
duration={p.duration}
duration_unit={p.duration_unit}
price={
+(
p.price -
p.price * (appliedDiscount / 100)
).toFixed(2)
}
/>
</div>
<div className="flex flex-col items-start gap-1">
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li>
<li>
- Gain insights into your weaknesses and strengths
</li>
<li>
- Allow yourself to correctly prepare for the exam
</li>
</ul>
</div>
</div>
))}
</div>
</div>
)}
{!isIndividual && entitiesThatCanBePaid.length > 0 && {!isIndividual &&
entity?.payment && ( entitiesThatCanBePaid.length > 0 &&
<div className="flex flex-col items-center gap-8"> entity?.payment && (
<div className={clsx("flex flex-col items-center gap-4 w-full")}> <div className="flex flex-col items-center gap-8">
<label className="font-normal text-base text-mti-gray-dim">Entity</label> <div
<Select className={clsx("flex flex-col items-center gap-4 w-full")}
defaultValue={{ value: entity?.id, label: entity?.label }} >
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))} <label className="font-normal text-base text-mti-gray-dim">
onChange={(e) => e?.value ? setEntity(e?.entity) : null} Entity
className="!w-full max-w-[400px] self-center" </label>
/> <Select
</div> defaultValue={{ value: entity?.id, 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"
/>
</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
<div className="mb-2 flex flex-col items-start"> className={clsx(
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> "flex flex-col items-start gap-6 rounded-xl bg-white p-4"
<span className="text-xl font-semibold"> )}
EnCoach - {12} Months >
</span> <div className="mb-2 flex flex-col items-start">
</div> <img
<div className="flex w-full flex-col items-start gap-2"> src="/logo_title.png"
<span className="text-2xl"> alt="EnCoach's Logo"
{entity.payment.price} {entity.payment.currency} className="w-32"
</span> />
<PaymobPayment <span className="text-xl font-semibold">
user={user} EnCoach - {12} Months
setIsPaymentLoading={setIsLoading} </span>
entity={entity} </div>
currency={entity.payment.currency} <div className="flex w-full flex-col items-start gap-2">
price={entity.payment.price} <span className="text-2xl">
duration={12} {entity.payment.price} {entity.payment.currency}
duration_unit="months" </span>
onSuccess={() => { <PaymobPayment
setIsLoading(false); user={user}
setTimeout(reload, 500); setIsPaymentLoading={setIsLoading}
}} entity={entity}
/> currency={entity.payment.currency}
</div> price={entity.payment.price}
<div className="flex flex-col items-start gap-1"> duration={12}
<span>This includes:</span> duration_unit="months"
<ul className="flex flex-col items-start text-sm"> onSuccess={() => {
<li> setIsLoading(false);
- Allow a total of {entity.licenses} students and teachers to use EnCoach setTimeout(reload, 500);
</li> }}
<li>- Train their abilities for the IELTS exam</li> />
<li>- Gain insights into your students&apos; weaknesses and strengths</li> </div>
<li>- Allow them to correctly prepare for the exam</li> <div className="flex flex-col items-start gap-1">
</ul> <span>This includes:</span>
</div> <ul className="flex flex-col items-start text-sm">
</div> <li>
</div> - Allow a total of {entity.licenses} students and
)} teachers to use EnCoach
{!isIndividual && entitiesThatCanBePaid.length === 0 && ( </li>
<div className="flex flex-col items-center"> <li>- Train their abilities for the IELTS exam</li>
<span className="max-w-lg"> <li>
You are not the person in charge of your time credits, please contact your administrator about this situation. - Gain insights into your students&apos; weaknesses and
</span> strengths
<span className="max-w-lg"> </li>
If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your <li>- Allow them to correctly prepare for the exam</li>
patience. </ul>
</span> </div>
</div> </div>
)} </div>
{!isIndividual && )}
entitiesThatCanBePaid.length > 0 && {!isIndividual && entitiesThatCanBePaid.length === 0 && (
!entity?.payment && ( <div className="flex flex-col items-center">
<div className="flex flex-col items-center gap-8"> <span className="max-w-lg">
<div className={clsx("flex flex-col items-center gap-4 w-full")}> You are not the person in charge of your time credits, please
<label className="font-normal text-base text-mti-gray-dim">Entity</label> contact your administrator about this situation.
<Select </span>
defaultValue={{ value: entity?.id || "", label: entity?.label || "" }} <span className="max-w-lg">
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))} If you believe this to be a mistake, please contact the
onChange={(e) => e?.value ? setEntity(e?.entity) : null} platform&apos;s administration, thank you for your patience.
className="!w-full max-w-[400px] self-center" </span>
/> </div>
</div> )}
<span className="max-w-lg"> {!isIndividual &&
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users entitiesThatCanBePaid.length > 0 &&
you desire and your expected monthly duration. !entity?.payment && (
</span> <div className="flex flex-col items-center gap-8">
<span className="max-w-lg"> <div
Please try again later or contact your agent or an admin, thank you for your patience. className={clsx("flex flex-col items-center gap-4 w-full")}
</span> >
</div> <label className="font-normal text-base text-mti-gray-dim">
)} Entity
</div> </label>
</Layout> <Select
</> defaultValue={{
); value: entity?.id || "",
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"
/>
</div>
<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 you desire
and your expected monthly duration.
</span>
<span className="max-w-lg">
Please try again later or contact your agent or an admin,
thank you for your patience.
</span>
</div>
)}
</div>
</>
</>
);
} }

View File

@@ -1,35 +1,69 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import type {AppProps} from "next/app"; import type { AppProps } from "next/app";
import "primereact/resources/themes/lara-light-indigo/theme.css"; 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 {reset} = useExamStore(); const [loading, setLoading] = useState(false);
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
const router = useRouter(); const { reset } = useExamStore();
useEffect(() => { const setIsSidebarMinimized = usePreferencesStore(
if (router.pathname !== "/exam" && router.pathname !== "/exercises") reset(); (state) => state.setSidebarMinimized
}, [router.pathname, reset]); );
useEffect(() => { const router = useRouter();
if (localStorage.getItem("isSidebarMinimized")) {
if (localStorage.getItem("isSidebarMinimized") === "true") {
setIsSidebarMinimized(true);
} else {
setIsSidebarMinimized(false);
}
}
}, [setIsSidebarMinimized]);
return <Component {...pageProps} />; const { entities } = useEntities(!pageProps?.user?.id);
useEffect(() => {
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]);
useEffect(() => {
if (localStorage.getItem("isSidebarMinimized")) {
if (localStorage.getItem("isSidebarMinimized") === "true") {
setIsSidebarMinimized(true);
} else {
setIsSidebarMinimized(false);
}
}
}, [setIsSidebarMinimized]);
return pageProps?.user ? (
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
{loading ? <UserProfileSkeleton /> : <Component {...pageProps} />}
</Layout>
) : (
<Component {...pageProps} />
);
} }

View File

@@ -4,21 +4,71 @@ import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { UserSolution } from "@/interfaces/exam"; import { UserSolution } from "@/interfaces/exam";
import { speakingReverseMarking, writingReverseMarking } from "@/utils/score"; import { speakingReverseMarking, writingReverseMarking } from "@/utils/score";
import { Stat } from "@/interfaces/user";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} }
const { sessionId, userId, userSolutions } = req.body; try {
return await getSessionEvals(req, res);
} catch (error) {
console.error(error);
res.status(500).json({ ok: false });
}
}
function formatSolutionWithEval(userSolution: UserSolution | Stat, evaluation: any) {
if (userSolution.type === 'writing') {
return {
...userSolution,
solutions: [{
...userSolution.solutions[0],
evaluation: evaluation.result
}],
score: {
correct: writingReverseMarking[evaluation.result.overall],
total: 100,
missing: 0
},
isDisabled: false
};
}
if (userSolution.type === 'speaking' || userSolution.type === 'interactiveSpeaking') {
return {
...userSolution,
solutions: [{
...userSolution.solutions[0],
...(
userSolution.type === 'speaking'
? { fullPath: evaluation.result.fullPath }
: { answer: evaluation.result.answer }
),
evaluation: evaluation.result
}],
score: {
correct: speakingReverseMarking[evaluation.result.overall || 0] || 0,
total: 100,
missing: 0
},
isDisabled: false
};
}
return {
solution: userSolution,
evaluation
};
}
async function getSessionEvals(req: NextApiRequest, res: NextApiResponse) {
const { sessionId, userId, stats } = req.body;
const completedEvals = await db.collection("evaluation").find({ const completedEvals = await db.collection("evaluation").find({
session_id: sessionId, session_id: sessionId,
user: userId, user: userId,
@@ -29,52 +79,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
completedEvals.map(e => [e.exercise_id, e]) completedEvals.map(e => [e.exercise_id, e])
); );
const solutionsWithEvals = userSolutions.filter((solution: UserSolution) => const statsWithEvals = stats
evalsByExercise.has(solution.exercise) .filter((solution: UserSolution | Stat) => evalsByExercise.has(solution.exercise))
).map((solution: any) => { .map((solution: UserSolution | Stat) =>
const evaluation = evalsByExercise.get(solution.exercise)!; formatSolutionWithEval(solution, evalsByExercise.get(solution.exercise)!)
);
if (solution.type === 'writing') { res.status(200).json(statsWithEvals);
return {
...solution,
solutions: [{
...solution.solutions[0],
evaluation: evaluation.result
}],
score: {
correct: writingReverseMarking[evaluation.result.overall],
total: 100,
missing: 0
},
isDisabled: false
};
}
if (solution.type === 'speaking' || solution.type === 'interactiveSpeaking') {
return {
...solution,
solutions: [{
...solution.solutions[0],
...(
solution.type === 'speaking'
? { fullPath: evaluation.result.fullPath }
: { answer: evaluation.result.answer }
),
evaluation: evaluation.result
}],
score: {
correct: speakingReverseMarking[evaluation.result.overall || 0] || 0,
total: 100,
missing: 0
},
isDisabled: false
};
}
return {
solution,
evaluation
};
});
res.status(200).json(solutionsWithEvals)
} }

View File

@@ -11,19 +11,100 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
} }
type Query = {
op: string;
sessionId: string;
userId: string;
}
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); return res.status(401).json({ ok: false });
return;
} }
const { sessionId, userId } = req.query; const { sessionId, userId, op } = req.query as Query;
switch (op) {
case 'pending':
return getPendingEvaluation(userId, sessionId, res);
case 'disabled':
return getSessionsWIthDisabledWithPending(userId, res);
default:
return res.status(400).json({
ok: false,
});
}
}
async function getPendingEvaluation(
userId: string,
sessionId: string,
res: NextApiResponse
) {
const singleEval = await db.collection("evaluation").findOne({ const singleEval = await db.collection("evaluation").findOne({
session_id: sessionId, session_id: sessionId,
user: userId, user: userId,
status: "pending", status: "pending",
}); });
return res.status(200).json({ hasPendingEvaluation: singleEval !== null });
res.status(200).json({ hasPendingEvaluation: singleEval !== null}); }
async function getSessionsWIthDisabledWithPending(
userId: string,
res: NextApiResponse
) {
const sessions = await db.collection("stats")
.aggregate([
{
$match: {
user: userId,
disabled: true
}
},
{
$project: {
_id: 0,
session: 1
}
},
{
$lookup: {
from: "evaluation",
let: { sessionId: "$session" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$session", "$$sessionId"] },
{ $eq: ["$user", userId] },
{ $eq: ["$status", "pending"] }
]
}
}
},
{
$project: {
_id: 1
}
}
],
as: "pendingEvals"
}
},
{
$match: {
"pendingEvals.0": { $exists: true }
}
},
{
$group: {
id: "$session"
}
}
]).toArray();
return res.status(200).json({
sessions: sessions.map(s => s.id)
});
} }

View File

@@ -3,37 +3,41 @@ import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb"; 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 { Stat } from "@/interfaces/user";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { UserSolution } from "@/interfaces/exam"; import { UserSolution } from "@/interfaces/exam";
import { WithId } from "mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
}
interface Body {
solutions: UserSolution[];
sessionID: string;
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
const { solutions, sessionID } = req.body as Body; if (req.method === "POST") return post(req, res);
}
const disabledStats = await db.collection("stats").find({ user: user.id, session: sessionID, disabled: true }).toArray();
interface Body {
solutions: UserSolution[];
sessionId: string;
userId: string;
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const { userId, solutions, sessionId } = req.body as Body;
const disabledStats = await db.collection("stats").find(
{ user: userId, session: sessionId, isDisabled: true }
).toArray();
await Promise.all(disabledStats.map(async (stat) => { await Promise.all(disabledStats.map(async (stat) => {
const matchingSolution = solutions.find(s => s.exercise === stat.exercise); const matchingSolution = solutions.find(s => s.exercise === stat.exercise);
if (matchingSolution) { if (matchingSolution) {
const { _id, ...updateFields } = matchingSolution as WithId<UserSolution>;
await db.collection("stats").updateOne( await db.collection("stats").updateOne(
{ id: stat.id }, { id: stat.id },
{ $set: { ...matchingSolution } } { $set: { ...updateFields } }
); );
} }
})); }));

View File

@@ -0,0 +1,21 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {session} = req.query;
const snapshot = await db.collection("stats").find({ user: req.session.user.id, session }).toArray();
res.status(200).json(snapshot);
}

View File

@@ -1,24 +1,20 @@
// 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);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
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,191 +1,220 @@
/* 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, BsPeopleFill,
BsPeopleFill, BsPersonFill,
BsPersonFill, BsPersonFillGear,
BsPersonFillGear,
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; 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 entities = await getEntitiesWithRoles(); const latestStudents = await getUsers(
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } }); { type: "student" },
const groupsCount = await countGroups(); 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 stats = await getStatsByUsers(mapBy(students, 'id')); const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) }; const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"),
{ archived: { $ne: true } }
);
const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, "id"));
return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
stats,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ export default function Dashboard({
user, user,
students, students,
latestStudents, latestStudents,
latestTeachers, latestTeachers,
usersCount, usersCount,
entities, entities,
assignmentsCount, assignmentsCount,
stats, stats,
groupsCount groupsCount,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<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")}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={usersCount.student} value={usersCount.student}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => router.push("/users?type=teacher")} onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare} Icon={BsPencilSquare}
label="Teachers" label="Teachers"
value={usersCount.teacher} value={usersCount.teacher}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsBank} Icon={BsBank}
onClick={() => router.push("/users?type=corporate")} onClick={() => router.push("/users?type=corporate")}
label="Corporates" label="Corporates"
value={usersCount.corporate} value={usersCount.corporate}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsBank} Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")} onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates" label="Master Corporates"
value={usersCount.mastercorporate} value={usersCount.mastercorporate}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPeople} Icon={BsPeople}
onClick={() => router.push("/classrooms")} onClick={() => router.push("/classrooms")}
label="Classrooms" label="Classrooms"
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
onClick={() => router.push("/entities")} Icon={BsPeopleFill}
label="Entities" onClick={() => router.push("/entities")}
value={entities.length} label="Entities"
color="purple" value={entities.length}
/> color="purple"
<IconCard Icon={BsPersonFillGear} />
onClick={() => router.push("/statistical")} <IconCard
label="Entity Statistics" Icon={BsPersonFillGear}
value={entities.length} onClick={() => router.push("/statistical")}
color="purple" label="Entity Statistics"
/> value={entities.length}
<IconCard Icon={BsPersonFillGear} color="purple"
onClick={() => router.push("/users/performance")} />
label="Student Performance" <IconCard
value={usersCount.student} Icon={BsPersonFillGear}
color="purple" onClick={() => router.push("/users/performance")}
/> label="Student Performance"
<IconCard value={usersCount.student}
Icon={BsEnvelopePaper} color="purple"
onClick={() => router.push("/assignments")} />
label="Assignments" <IconCard
value={assignmentsCount} Icon={BsEnvelopePaper}
color="purple" onClick={() => router.push("/assignments")}
/> label="Assignments"
</section> value={assignmentsCount}
color="purple"
/>
</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 <UserDisplayList users={latestStudents} title="Latest Students" />
users={latestStudents} <UserDisplayList users={latestTeachers} title="Latest Teachers" />
title="Latest Students" <UserDisplayList users={students} title="Highest level students" />
/> <UserDisplayList
<UserDisplayList users={students.sort(
users={latestTeachers} (a, b) =>
title="Latest Teachers" Object.keys(groupByExam(filterBy(stats, "user", b))).length -
/> Object.keys(groupByExam(filterBy(stats, "user", a))).length
<UserDisplayList )}
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} title="Highest exam count students"
title="Highest level students" />
/> </section>
<UserDisplayList </>
users={ </>
students );
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
} }

View File

@@ -1,194 +1,265 @@
/* 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, BsPeople,
BsPeople, BsPeopleFill,
BsPeopleFill, BsPersonFill,
BsPersonFill, BsPersonFillGear,
BsPersonFillGear,
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
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[];
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", "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,
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') userCounts,
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') entities,
assignmentsCount,
const router = useRouter(); stats = [],
groupsCount,
return ( }: Props) {
<> const totalCount = useMemo(
<Head> () =>
<title>EnCoach</title> userCounts.corporate +
<meta userCounts.mastercorporate +
name="description" userCounts.student +
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." userCounts.teacher,
/> [userCounts]
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={userCounts.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={userCounts.teacher}
color="purple"
/>
<IconCard
onClick={() => router.push("/classrooms")}
Icon={BsPeople}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
{allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={allowedEntityStatistics.length}
color="purple"
/>
)}
{allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={userCounts.student}
color="purple"
/>
)}
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsEnvelopePaper}
className="col-span-2"
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
color="purple"
/>
</section>
</div>
<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
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
); );
const totalLicenses = useMemo(
() =>
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();
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={userCounts.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={userCounts.teacher}
color="purple"
/>
<IconCard
onClick={() => router.push("/classrooms")}
Icon={BsPeople}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
{allowedEntityStatistics.length > 0 && (
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={allowedEntityStatistics.length}
color="purple"
/>
)}
{allowedStudentPerformance.length > 0 && (
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={userCounts.student}
color="purple"
/>
)}
<IconCard
Icon={BsClock}
label="Expiration Date"
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
Icon={BsEnvelopePaper}
className="col-span-2"
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
color="purple"
/>
</section>
</div>
<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
users={students.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length
)}
title="Highest exam count students"
/>
</section>
</>
</>
);
} }

View File

@@ -1,189 +1,214 @@
/* 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, BsPeopleFill,
BsPeopleFill, BsPersonFill,
BsPersonFill, BsPersonFillGear,
BsPersonFillGear,
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; 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" },
const groupsCount = await countGroups(); 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 }
);
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, groupsCount }) }; 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();
return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ export default function Dashboard({
user, user,
students = [], students = [],
latestStudents, latestStudents,
latestTeachers, latestTeachers,
usersCount, usersCount,
entities, entities,
assignmentsCount, assignmentsCount,
stats = [], stats = [],
groupsCount groupsCount,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<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")}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={usersCount.student} value={usersCount.student}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => router.push("/users?type=teacher")} onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare} Icon={BsPencilSquare}
label="Teachers" label="Teachers"
value={usersCount.teacher} value={usersCount.teacher}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsBank} Icon={BsBank}
onClick={() => router.push("/users?type=corporate")} onClick={() => router.push("/users?type=corporate")}
label="Corporates" label="Corporates"
value={usersCount.corporate} value={usersCount.corporate}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsBank} Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")} onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates" label="Master Corporates"
value={usersCount.mastercorporate} value={usersCount.mastercorporate}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPeople} Icon={BsPeople}
onClick={() => router.push("/classrooms")} onClick={() => router.push("/classrooms")}
label="Classrooms" label="Classrooms"
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
onClick={() => router.push("/entities")} Icon={BsPeopleFill}
label="Entities" onClick={() => router.push("/entities")}
value={entities.length} label="Entities"
color="purple" value={entities.length}
/> color="purple"
<IconCard Icon={BsPersonFillGear} />
onClick={() => router.push("/statistical")} <IconCard
label="Entity Statistics" Icon={BsPersonFillGear}
value={entities.length} onClick={() => router.push("/statistical")}
color="purple" label="Entity Statistics"
/> value={entities.length}
<IconCard Icon={BsPersonFillGear} color="purple"
onClick={() => router.push("/users/performance")} />
label="Student Performance" <IconCard
value={usersCount.student} Icon={BsPersonFillGear}
color="purple" onClick={() => router.push("/users/performance")}
/> label="Student Performance"
<IconCard value={usersCount.student}
Icon={BsEnvelopePaper} color="purple"
onClick={() => router.push("/assignments")} />
label="Assignments" <IconCard
value={assignmentsCount} Icon={BsEnvelopePaper}
color="purple" onClick={() => router.push("/assignments")}
/> label="Assignments"
</section> value={assignmentsCount}
color="purple"
/>
</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 <UserDisplayList users={latestStudents} title="Latest Students" />
users={latestStudents} <UserDisplayList users={latestTeachers} title="Latest Teachers" />
title="Latest Students" <UserDisplayList users={students} title="Highest level students" />
/> <UserDisplayList
<UserDisplayList users={students.sort(
users={latestTeachers} (a, b) =>
title="Latest Teachers" Object.keys(groupByExam(filterBy(stats, "user", b.id))).length -
/> Object.keys(groupByExam(filterBy(stats, "user", a.id))).length
<UserDisplayList )}
users={students} title="Highest exam count students"
title="Highest level students" />
/> </section>
<UserDisplayList </>
users={ </>
students );
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
} }

View File

@@ -1,203 +1,266 @@
/* 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, BsPencilSquare,
BsPaperclip, BsPeople,
BsPencilSquare, BsPeopleFill,
BsPeople, BsPersonFill,
BsPeopleFill, BsPersonFillGear,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
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[];
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", "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(
(acc, curr) => acc + parseInt(curr.licenses.toString()),
0
),
[entities]
);
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) const router = useRouter();
const router = useRouter(); const allowedEntityStatistics = useAllowedEntities(
user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') return (
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') <>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={userCounts.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={userCounts.teacher}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=corporate")}
Icon={BsBank}
label="Corporate Accounts"
value={userCounts.corporate}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
{allowedStudentPerformance.length > 0 && (
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={userCounts.student}
color="purple"
/>
)}
{allowedEntityStatistics.length > 0 && (
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={allowedEntityStatistics.length}
color="purple"
/>
)}
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
className={clsx(
allowedEntityStatistics.length === 0 && "col-span-2"
)}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
</section>
return ( <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<> <UserDisplayList users={latestStudents} title="Latest Students" />
<Head> <UserDisplayList users={latestTeachers} title="Latest Teachers" />
<title>EnCoach</title> <UserDisplayList users={students} title="Highest level students" />
<meta <UserDisplayList
name="description" users={students.sort(
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." (a, b) =>
/> Object.keys(groupByExam(filterBy(stats, "user", b))).length -
<meta name="viewport" content="width=device-width, initial-scale=1" /> Object.keys(groupByExam(filterBy(stats, "user", a))).length
<link rel="icon" href="/favicon.ico" /> )}
</Head> title="Highest exam count students"
<ToastContainer /> />
<Layout user={user}> </section>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> </>
<IconCard </>
onClick={() => router.push("/users?type=student")} );
Icon={BsPersonFill}
label="Students"
value={userCounts.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={userCounts.teacher}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=corporate")}
Icon={BsBank}
label="Corporate Accounts"
value={userCounts.corporate}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
{allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={userCounts.student}
color="purple"
/>
)}
{allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={allowedEntityStatistics.length}
color="purple"
/>
)}
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
className={clsx(allowedEntityStatistics.length === 0 && "col-span-2")}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={latestStudents}
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</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,267 +9,368 @@ 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[];
grading: Grading; grading: Grading;
} }
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 invites = await getInvitesByInvitee(user.id);
const grading = await getGradingSystemByEntity(entityIDS[0] || "");
const formattedInvites = await Promise.all(invites.map(convertInvitersToEntity)); const assignmentsIDs = mapBy(assignments, "id");
const examIDs = uniqBy( const sessions = await getSessionsByUser(user.id, 10, {
assignments.flatMap((a) => ["assignment.id"]: { $in: assignmentsIDs },
a.exams.filter((e) => e.assignee === user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), });
), const invites = await getInvitesByInvitee(user.id);
"key", const grading = await getGradingSystemByEntity(entityIDS[0] || "", {
); _id: 0,
const exams = await getExamsByIds(examIDs); steps: 1,
});
return { props: serialize({ user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading }) }; const formattedInvites = await Promise.all(
invites.map(convertInvitersToEntity)
);
const examIDs = uniqBy(
assignments.flatMap((a) =>
a.exams.map((e: { module: string; id: string }) => ({
module: e.module,
id: e.id,
key: `${e.module}_${e.id}`,
}))
),
"key"
);
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
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({
const router = useRouter(); user,
entities,
assignments,
stats,
invites,
grading,
sessions,
exams,
}: Props) {
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",
exams: assignmentExams.sort(sortByModule), payload: {
modules: mapBy(assignmentExams.sort(sortByModule), 'module'), exams: assignmentExams.sort(sortByModule),
assignment modules: mapBy(assignmentExams.sort(sortByModule), "module"),
} assignment,
}) },
});
router.push("/exam"); router.push("/exam");
} }
}; };
const studentAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
{entities.length > 0 && (
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
return ( <ProfileSummary
<> user={user}
<Head> items={[
<title>EnCoach</title> {
<meta icon: (
name="description" <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." ),
/> value: stats.fullExams,
<meta name="viewport" content="width=device-width, initial-scale=1" /> label: "Exams",
<link rel="icon" href="/favicon.ico" /> tooltip: "Number of all conducted completed exams",
</Head> },
<ToastContainer /> {
<Layout user={user}> icon: (
{entities.length > 0 && ( <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> ),
<b>{mapBy(entities, "label")?.join(", ")}</b> value: stats.uniqueModules,
</div> label: "Modules",
)} tooltip:
"Number of all exam modules performed including Level Test",
},
{
icon: (
<BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: `${stats?.averageScore.toFixed(2) || 0}%`,
label: "Average Score",
tooltip: "Average success rate for questions responded",
},
]}
/>
<ProfileSummary {/* Assignments */}
user={user} <section className="flex flex-col gap-1 md:gap-3">
items={[ <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">
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, {assignments.length === 0 &&
value: countFullExams(stats), "Assignments will appear here. It seems that for now there are no assignments for you."}
label: "Exams", {assignments.map((assignment) => (
tooltip: "Number of all conducted completed exams", <div
}, className={clsx(
{ "border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, assignment.hasResults && "border-mti-green-light"
value: countExamModules(stats), )}
label: "Modules", key={assignment.id}
tooltip: "Number of all exam modules performed including Level Test", >
}, <div className="flex flex-col gap-1">
{ <h3 className="text-mti-black/90 text-xl font-semibold">
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, {assignment.name}
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, </h3>
label: "Average Score", <span className="flex justify-between gap-1 text-lg">
tooltip: "Average success rate for questions responded", <span>
}, {moment(assignment.startDate).format("DD/MM/YY, HH:mm")}
]} </span>
/> <span>-</span>
<span>
{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}
</span>
</span>
</div>
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
{assignment.exams.map((e) => (
<ModuleBadge
className="scale-110 w-full"
key={e.module}
module={e.module}
/>
))}
</div>
{!assignment.hasResults && (
<>
<div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"
>
<Button
className="h-full w-full !rounded-xl"
variant="outline"
>
Start
</Button>
</div>
<div
data-tip="You have already started this assignment!"
className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
sessions.filter(
(x) => x.assignment?.id === assignment.id
).length > 0 && "tooltip"
)}
>
<Button
className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)}
variant="outline"
disabled={
sessions.filter(
(x) => x.assignment?.id === assignment.id
).length > 0
}
>
Start
</Button>
</div>
</>
)}
{assignment.hasResults && (
<Button
onClick={() => router.push("/record")}
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline"
>
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
{/* Assignments */} {/* Invites */}
<section className="flex flex-col gap-1 md:gap-3"> {invites.length > 0 && (
<span className="text-mti-black text-lg font-bold">Assignments</span> <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">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."} {invites.map((invite) => (
{studentAssignments <InviteWithUserCard
.sort((a, b) => moment(a.startDate).diff(b.startDate)) key={invite.id}
.map((assignment) => ( invite={invite}
<div reload={() => router.replace(router.asPath)}
className={clsx( />
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4", ))}
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light", </span>
)} </section>
key={assignment.id}> )}
<div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
<span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
</div>
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment">
<Button className="h-full w-full !rounded-xl" variant="outline">
Start
</Button>
</div>
<div
data-tip="You have already started this assignment!"
className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
)}>
<Button
className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)}
variant="outline"
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
Start
</Button>
</div>
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
<Button
onClick={() => router.push("/record")}
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline">
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
{/* Invites */} {/* Score History */}
{invites.length > 0 && ( <section className="flex flex-col gap-3">
<section className="flex flex-col gap-1 md:gap-3"> <span className="text-lg font-bold">Score History</span>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{invites.map((invite) => ( {MODULE_ARRAY.map((module) => {
<InviteWithUserCard key={invite.id} invite={invite} reload={() => router.replace(router.asPath)} /> const desiredLevel = user.desiredLevels[module] || 9;
))} const level = user.levels[module] || 0;
</span> return (
</section> <div
)} className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 w-full"
key={module}
{/* Score History */} >
<section className="flex flex-col gap-3"> <div className="flex items-center gap-2 md:gap-3">
<span className="text-lg font-bold">Score History</span> <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="-md:grid-rows-4 grid gap-6 md:grid-cols-2"> {module === "reading" && (
{MODULE_ARRAY.map((module) => { <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
const desiredLevel = user.desiredLevels[module] || 9; )}
const level = user.levels[module] || 0; {module === "listening" && (
return ( <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}> )}
<div className="flex items-center gap-2 md:gap-3"> {module === "writing" && (
<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"> <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />} )}
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />} {module === "speaking" && (
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />} <BsMegaphone className="text-ielts-speaking 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" />} {module === "level" && (
</div> <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
<div className="flex w-full justify-between"> )}
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span> </div>
<span className="text-mti-gray-dim text-sm font-normal"> <div className="flex w-full justify-between">
{module === "level" && !!grading && `English Level: ${getGradingLabel(level, grading.steps)}`} <span className="text-sm font-bold md:font-extrabold">
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`} {capitalize(module)}
</span> </span>
</div> <span className="text-mti-gray-dim text-sm font-normal">
</div> {module === "level" &&
<div className="md:pl-14"> !!grading &&
<ProgressBar `English Level: ${getGradingLabel(
color={module} level,
label="" grading.steps
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)} )}`}
markLabel={`Desired Level: ${desiredLevel}`} {module !== "level" &&
percentage={module === "level" ? level : Math.round((level * 100) / 9)} `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
className="h-2 w-full" </span>
/> </div>
</div> </div>
</div> <div className="md:pl-14">
); <ProgressBar
})} color={module}
</div> label=""
</section> mark={
</Layout> module === "level"
</> ? undefined
); : Math.round((desiredLevel * 100) / 9)
}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={
module === "level"
? level
: Math.round((level * 100) / 9)
}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</section>
</>
</>
);
} }

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,138 +10,184 @@ 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[];
groups: Group[]; groups: Group[];
} }
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 assignments = await getEntitiesAssignments(entityIDS); const entities = await getEntitiesWithRoles(
const stats = await getStatsByUsers(users.map((u) => u.id)); isAdmin(user) ? undefined : entityIDS
const groups = await getGroupsByEntities(entityIDS); );
return { props: serialize({ user, users, entities, assignments, stats, groups }) }; 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 stats = await getStatsByUsers(students.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
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,
const router = useRouter(); students,
entities,
assignments,
stats,
groups,
}: Props) {
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 (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<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">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{mapBy(entities, "label")?.join(", ")}</b>
</div> </div>
)} )}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center"> <section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard <IconCard
Icon={BsPersonFill} Icon={BsPersonFill}
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
label="Students" label="Students"
value={students.length} value={students.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => router.push("/classrooms")} onClick={() => router.push("/classrooms")}
Icon={BsPeople} Icon={BsPeople}
label="Classrooms" label="Classrooms"
value={groups.length} value={groups.length}
color="purple" color="purple"
/> />
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
onClick={() => router.push("/users/performance")} Icon={BsPersonFillGear}
label="Student Performance" onClick={() => router.push("/users/performance")}
value={students.length} label="Student Performance"
color="purple" value={students.length}
/> color="purple"
)} />
{allowedEntityStatistics.length > 0 && ( )}
<IconCard Icon={BsPersonFillGear} {allowedEntityStatistics.length > 0 && (
onClick={() => router.push("/statistical")} <IconCard
label="Entity Statistics" Icon={BsPersonFillGear}
value={allowedEntityStatistics.length} onClick={() => router.push("/statistical")}
color="purple" label="Entity Statistics"
/> value={allowedEntityStatistics.length}
)} color="purple"
<IconCard />
Icon={BsEnvelopePaper} )}
onClick={() => router.push("/assignments")} <IconCard
label="Assignments" Icon={BsEnvelopePaper}
value={assignments.filter((a) => !a.archived).length} onClick={() => router.push("/assignments")}
color="purple" label="Assignments"
/> value={assignments.filter((a) => !a.archived).length}
</section> color="purple"
</div> />
</section>
</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 <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={students.sort((a, b) =>
title="Latest Students" dateSorter(a, b, "desc", "registrationDate")
/> )}
<UserDisplayList title="Latest Students"
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} />
title="Highest level students" <UserDisplayList
/> users={students.sort(
<UserDisplayList (a, b) =>
users={ calculateAverageLevel(b.levels) -
students calculateAverageLevel(a.levels)
.sort( )}
(a, b) => title="Highest level students"
Object.keys(groupByExam(filterBy(stats, "user", b))).length - />
Object.keys(groupByExam(filterBy(stats, "user", a))).length, <UserDisplayList
) users={students.sort(
} (a, b) =>
title="Highest exam count students" Object.keys(groupByExam(filterBy(stats, "user", b))).length -
/> Object.keys(groupByExam(filterBy(stats, "user", a))).length
</section> )}
</Layout> title="Highest exam count students"
</> />
); </section>
</>
</>
);
} }

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,99 +17,126 @@ 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 {
props: serialize({ user, entities: entitiesWithCount }), props: serialize({ user, entities: entitiesWithCount }),
}; };
}, sessionOptions); }, sessionOptions);
const SEARCH_FIELDS: string[][] = [["entity", "label"]]; const SEARCH_FIELDS: string[][] = [["entity", "label"]];
interface Props { interface Props {
user: User; user: User;
entities: EntitiesWithCount[]; entities: EntitiesWithCount[];
} }
export default function Home({ user, entities }: Props) { export default function Home({ user, entities }: Props) {
const renderCard = ({ entity, users, count }: EntitiesWithCount) => ( const renderCard = ({ entity, users, count }: EntitiesWithCount) => (
<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"> >
<span className="flex items-center gap-1"> <div className="flex flex-col gap-2 w-full">
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span> <span className="flex items-center gap-1">
{entity.label} <span className="bg-mti-purple text-white font-semibold px-2">
</span> Entity
<span className="flex items-center gap-1"> </span>
<span className="bg-mti-purple text-white font-semibold px-2">Members</span> {entity.label}
<span className="bg-mti-purple-light/50 px-2">{count}{isAdmin(user) && ` / ${entity.licenses || 0}`}</span> </span>
</span> <span className="flex items-center gap-1">
<span> <span className="bg-mti-purple text-white font-semibold px-2">
{users.map(getUserName).join(", ")}{' '} Members
{count > 5 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {count - 5} more</span> : ""} </span>
</span> <span className="bg-mti-purple-light/50 px-2">
</div> {count}
<div className="w-fit"> {isAdmin(user) && ` / ${entity.licenses || 0}`}
<BsBank className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" /> </span>
</div> </span>
</Link> <span>
); {users.map(getUserName).join(", ")}{" "}
{count > 5 ? (
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
and {count - 5} more
</span>
) : (
""
)}
</span>
</div>
<div className="w-fit">
<BsBank className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
</div>
</Link>
);
const firstCard = () => ( const firstCard = () => (
<Link <Link
href={`/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} /> >
<span className="font-semibold">Create Entity</span> <BsPlus size={40} />
</Link> <span className="font-semibold">Create Entity</span>
); </Link>
);
return ( return (
<> <>
<Head> <Head>
<title>Entities | EnCoach</title> <title>Entities | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<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>
<Separator /> <Separator />
</div> </div>
<CardList<EntitiesWithCount> <CardList<EntitiesWithCount>
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> }
</Layout> />
</> </section>
); </>
</>
);
} }

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";
@@ -34,142 +36,187 @@ import { BsArrowRepeat } 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: Assignment[];
stats: Stat[]; stats: Stat[];
exams: Exam[]; exams: Exam[];
sessions: Session[]; sessions: Session[];
invites: InviteWithEntity[]; invites: InviteWithEntity[];
grading: Grading; grading: Grading;
} }
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) => ({
"key", module: e.module,
); id: e.id,
const exams = await getExamsByIds(examIDs); key: `${e.module}_${e.id}`,
})
)
),
"key"
);
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",
exams: assignmentExams.sort(sortByModule), payload: {
modules: mapBy(assignmentExams.sort(sortByModule), 'module'), exams: assignmentExams.sort(sortByModule),
assignment modules: mapBy(assignmentExams.sort(sortByModule), "module"),
} 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 () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
const studentAssignments = useMemo(() => [ const studentAssignments = useMemo(
...assignments.filter(activeAssignmentFilter), ...assignments.filter(futureAssignmentFilter)], () => [
[assignments] ...assignments.filter(activeAssignmentFilter),
); ...assignments.filter(futureAssignmentFilter),
],
[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 (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<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>
</div> </div>
)} )}
<ProfileSummary user={user} items={[]} removeLevel /> <ProfileSummary user={user} items={[]} removeLevel />
<Separator /> <Separator />
{/* Assignments */} {/* Assignments */}
<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">
</div> Assignments
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> </span>
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."} <BsArrowRepeat
{studentAssignments className={clsx("text-xl", isLoading && "animate-spin")}
.sort((a, b) => moment(a.startDate).diff(b.startDate)) />
.map((a) => </div>
<AssignmentCard <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
key={a.id} {studentAssignments.length === 0 &&
assignment={a} "Assignments will appear here. It seems that for now there are no assignments for you."}
user={user} {studentAssignments
session={assignmentSessions.find(s => s.assignment?.id === a.id)} .sort((a, b) => moment(a.startDate).diff(b.startDate))
startAssignment={startAssignment} .map((a) => (
resumeAssignment={loadSession} <AssignmentCard
/> key={a.id}
)} assignment={a}
</span> user={user}
</section> session={assignmentSessions.find(
(s) => s.assignment?.id === a.id
)}
startAssignment={startAssignment}
resumeAssignment={loadSession}
/>
))}
</span>
</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,188 +1,219 @@
/* 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";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import axios from "axios"; import axios from "axios";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import {Type as UserType} from "@/interfaces/user"; import { Type as UserType } from "@/interfaces/user";
import {getGroups} from "@/utils/groups.be"; import { getGroups } from "@/utils/groups.be";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
interface BasicUser { interface BasicUser {
id: string; id: string;
name: string; name: string;
type: UserType; type: UserType;
} }
interface PermissionWithBasicUsers { interface PermissionWithBasicUsers {
id: string; id: string;
type: PermissionType; type: PermissionType;
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);
const allUserData: User[] = await getUsers(); const allUserData: User[] = await getUsers();
const groups = await getGroups(); const groups = await getGroups();
const userGroups = groups.filter((x) => x.admin === user.id); const userGroups = groups.filter((x) => x.admin === user.id);
const filteredGroups = const filteredGroups =
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) =>
: groups; userGroups.flatMap((y) => y.participants).includes(x.admin)
)
: groups;
const users = allUserData.map((u) => ({ const users = allUserData.map((u) => ({
id: u.id, id: u.id,
name: u.name, name: u.name,
type: u.type, type: u.type,
})) 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) =>
: users; filteredGroups.flatMap((g) => g.participants).includes(u.id)
)
: 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(
const user = filteredUsers.find((u) => u.id === userId) as BasicUser; (acc: BasicUser[], userId) => {
if (!!user) acc.push(user); const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
return acc; if (!!user) acc.push(user);
}, []); return acc;
},
[]
);
return { return {
props: { props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })), // permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permission: { permission: {
...permission, ...permission,
id: params.id, id: params.id,
users: usersData, users: usersData,
}, },
user, user,
users: filteredUsers, users: filteredUsers,
}, },
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
permission: PermissionWithBasicUsers; permission: PermissionWithBasicUsers;
user: User; user: User;
users: BasicUser[]; users: BasicUser[];
} }
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) => {
if (value?.value) { if (value?.value) {
return [...prev, value?.value]; return [...prev, value?.value];
} }
return prev; return prev;
}); });
}; };
const removeUser = (id: string) => { const removeUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((u) => u !== id)); setSelectedUsers((prev) => prev.filter((u) => u !== id));
}; };
const update = async () => { const update = async () => {
try { try {
await axios.patch(`/api/permissions/${permission.id}`, { await axios.patch(`/api/permissions/${permission.id}`, {
users: selectedUsers, users: selectedUsers,
}); });
toast.success("Permission updated"); toast.success("Permission updated");
} catch (err) { } catch (err) {
toast.error("Failed to update permission"); toast.error("Failed to update permission");
} }
}; };
return ( const { setClassName } = React.useContext(LayoutContext);
<>
<Head> useEffect(() => {
<title>EnCoach</title> setClassName("gap-6");
<meta return () => setClassName("");
name="description" }, [setClassName]);
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> return (
<meta name="viewport" content="width=device-width, initial-scale=1" /> <>
<link rel="icon" href="/favicon.ico" /> <Head>
</Head> <title>EnCoach</title>
<ToastContainer /> <meta
<Layout user={user} className="gap-6"> name="description"
<div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl"> content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
<h1 className="text-2xl font-semibold">Permission: {permission.type as string}</h1> />
<div className="flex gap-3"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<Select <link rel="icon" href="/favicon.ico" />
value={null} </Head>
options={users <ToastContainer />
.filter((u) => !selectedUsers.includes(u.id)) <>
.map((u) => ({ <div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl">
label: `${u?.type}-${u?.name}`, <h1 className="text-2xl font-semibold">
value: u.id, Permission: {permission.type as string}
}))} </h1>
onChange={onChange} <div className="flex gap-3">
/> <Select
<Button onClick={update}>Update</Button> value={null}
</div> options={users
<div className="flex flex-row justify-between"> .filter((u) => !selectedUsers.includes(u.id))
<div className="flex flex-col gap-3"> .map((u) => ({
<h2>Blacklisted Users</h2> label: `${u?.type}-${u?.name}`,
<div className="flex gap-3 flex-wrap"> value: u.id,
{selectedUsers.map((userId) => { }))}
const user = users.find((u) => u.id === userId); onChange={onChange}
return ( />
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={userId}> <Button onClick={update}>Update</Button>
<span className="text-base first-letter:uppercase"> </div>
{user?.type}-{user?.name} <div className="flex flex-row justify-between">
</span> <div className="flex flex-col gap-3">
<BsTrash style={{cursor: "pointer"}} onClick={() => removeUser(userId)} size={20} /> <h2>Blacklisted Users</h2>
</div> <div className="flex gap-3 flex-wrap">
); {selectedUsers.map((userId) => {
})} const user = users.find((u) => u.id === userId);
</div> return (
</div> <div
<div className="flex flex-col gap-3"> className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
<h2>Whitelisted Users</h2> key={userId}
<div className="flex flex-col gap-3 flex-wrap"> >
{users <span className="text-base first-letter:uppercase">
.filter((user) => !selectedUsers.includes(user.id)) {user?.type}-{user?.name}
.map((user) => { </span>
return ( <BsTrash
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={user.id}> style={{ cursor: "pointer" }}
<span className="text-base first-letter:uppercase"> onClick={() => removeUser(userId)}
{user?.type}-{user?.name} size={20}
</span> />
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
</div> <div className="flex flex-col gap-3">
</div> <h2>Whitelisted Users</h2>
</Layout> <div className="flex flex-col gap-3 flex-wrap">
</> {users
); .filter((user) => !selectedUsers.includes(user.id))
.map((user) => {
return (
<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">
{user?.type}-{user?.name}
</span>
</div>
);
})}
</div>
</div>
</div>
</div>
</>
</>
);
} }

View File

@@ -1,73 +1,85 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; 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;
}); });
// 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
return { return {
props: { props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })), // permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permissions: filteredPermissions.map((p) => { permissions: filteredPermissions.map((p) => {
const {users, ...rest} = p; const { users, ...rest } = p;
return rest; return rest;
}), }),
user, user,
}, },
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
permissions: Permission[]; permissions: Permission[];
user: User; user: User;
} }
export default function Page(props: Props) { export default function Page(props: Props) {
const {permissions, user} = props; const { permissions, user } = props;
return ( const { setClassName } = React.useContext(LayoutContext);
<> React.useEffect(() => setClassName("gap-6"), [setClassName]);
<Head>
<title>EnCoach</title> return (
<meta <>
name="description" <Head>
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." <title>EnCoach</title>
/> <meta
<meta name="viewport" content="width=device-width, initial-scale=1" /> name="description"
<link rel="icon" href="/favicon.ico" /> content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
</Head> />
<Layout user={user} className="gap-6"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<h1 className="text-2xl font-semibold">Permissions</h1> <link rel="icon" href="/favicon.ico" />
<div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl"> </Head>
<PermissionList permissions={permissions} /> <>
</div> <h1 className="text-2xl font-semibold">Permissions</h1>
</Layout> <div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
</> <PermissionList permissions={permissions} />
); </div>
</>
</>
);
} }

File diff suppressed because it is too large Load Diff

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";
@@ -30,6 +29,8 @@ import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import getPendingEvals from "@/utils/disabled.be";
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
@@ -45,9 +46,10 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds)) const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds))
const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds)) const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds))
const gradingSystems = await getGradingSystemByEntities(entitiesIds) const gradingSystems = await getGradingSystemByEntities(entitiesIds)
const pendingSessionIds = await getPendingEvals(user.id);
return { return {
props: serialize({ user, users, assignments, entities, gradingSystems,isAdmin }), props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -59,12 +61,13 @@ interface Props {
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[]
gradingSystems: Grading[] gradingSystems: Grading[]
pendingSessionIds: string[];
isAdmin:boolean isAdmin:boolean
} }
const MAX_TRAINING_EXAMS = 10; const MAX_TRAINING_EXAMS = 10;
export default function History({ user, users, assignments, entities, gradingSystems,isAdmin }: Props) { export default function History({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }: Props) {
const router = useRouter(); const router = useRouter();
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
state.selectedUser, state.selectedUser,
@@ -86,9 +89,11 @@ export default function History({ user, users, assignments, entities, gradingSys
const groupedStats = useMemo(() => groupByDate( const groupedStats = useMemo(() => groupByDate(
stats.filter((x) => { stats.filter((x) => {
if ( if (
(x.module === "writing" || x.module === "speaking") && (
!x.isDisabled && x.module === "writing" || x.module === "speaking") &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation")) !x.isDisabled && Array.isArray(x.solutions) &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation")
)
) )
return false; return false;
return true; return true;
@@ -180,6 +185,8 @@ export default function History({ user, users, assignments, entities, gradingSys
); );
}; };
useEvaluationPolling(pendingSessionIds ? pendingSessionIds : [], "records", user.id);
return ( return (
<> <>
<Head> <Head>
@@ -193,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">
@@ -226,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

@@ -26,7 +26,6 @@ export const initialState: ExamState = {
inactivity: 0, inactivity: 0,
shuffles: [], shuffles: [],
bgColor: "bg-white", bgColor: "bg-white",
evaluated: [],
user: undefined, user: undefined,
navigation: { navigation: {
previousDisabled: false, previousDisabled: false,
@@ -39,7 +38,6 @@ export const initialState: ExamState = {
reviewAll: false, reviewAll: false,
finalizeModule: false, finalizeModule: false,
finalizeExam: false, finalizeExam: false,
pendingEvaluation: false,
}, },
}; };
@@ -62,8 +60,6 @@ const useExamStore = create<ExamState & ExamFunctions>((set, get) => ({
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })), setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
setBgColor: (bgColor: string) => set(() => ({ bgColor })), setBgColor: (bgColor: string) => set(() => ({ bgColor })),
setEvaluated: (evaluated: UserSolution[]) => set(() => ({ evaluated })),
setNavigation: (updates: Partial<Navigation>) => set((state) => ({ setNavigation: (updates: Partial<Navigation>) => set((state) => ({
navigation: { navigation: {
...state.navigation, ...state.navigation,
@@ -166,7 +162,7 @@ export const usePersistentExamStore = create<ExamState & ExamFunctions>()(
saveStats: async () => { }, saveStats: async () => { },
saveSession: async () => { }, saveSession: async () => { },
setEvaluated: (evaluated: UserSolution[]) => {}, setEvalSolutions: (evaluated: UserSolution[]) => {},
reset: () => set(() => initialState), reset: () => set(() => initialState),
dispatch: (action) => set((state) => rootReducer(state, action)) dispatch: (action) => set((state) => rootReducer(state, action))

View File

@@ -93,20 +93,11 @@ export const rootReducer = (
}; };
case 'FINALIZE_MODULE': { case 'FINALIZE_MODULE': {
const { updateTimers } = action.payload; const { updateTimers } = action.payload;
const solutions = state.userSolutions;
const evaluated = state.evaluated;
const hasUnevaluatedSolutions = solutions.some(solution =>
(solution.type === 'speaking' ||
solution.type === 'writing' ||
solution.type === 'interactiveSpeaking') &&
!evaluated.some(evaluation => evaluation.exercise === solution.exercise)
);
// To finalize a module first flag the timers to be updated // To finalize a module first flag the timers to be updated
if (updateTimers) { if (updateTimers) {
return { return {
flags: { ...state.flags, finalizeModule: true, pendingEvaluation: hasUnevaluatedSolutions } flags: { ...state.flags, finalizeModule: true }
} }
} else { } else {
// then check whether there are more modules in the exam, if there are // then check whether there are more modules in the exam, if there are
@@ -118,7 +109,6 @@ export const rootReducer = (
...state.flags, ...state.flags,
finalizeModule: false, finalizeModule: false,
finalizeExam: true, finalizeExam: true,
pendingEvaluation: hasUnevaluatedSolutions,
} }
} }
} else if (state.moduleIndex < state.selectedModules.length - 1) { } else if (state.moduleIndex < state.selectedModules.length - 1) {

View File

@@ -16,7 +16,6 @@ export interface StateFlags {
reviewAll: boolean; reviewAll: boolean;
finalizeModule: boolean; finalizeModule: boolean;
finalizeExam: boolean; finalizeExam: boolean;
pendingEvaluation: boolean;
} }
export interface ExamState { export interface ExamState {
@@ -39,8 +38,7 @@ export interface ExamState {
user: undefined | string; user: undefined | string;
currentSolution?: UserSolution | undefined; currentSolution?: UserSolution | undefined;
navigation: Navigation; navigation: Navigation;
flags: StateFlags, flags: StateFlags;
evaluated: UserSolution[];
} }
@@ -65,8 +63,6 @@ export interface ExamFunctions {
setTimeIsUp: (timeIsUp: boolean) => void; setTimeIsUp: (timeIsUp: boolean) => void;
setEvaluated: (evaluated: UserSolution[]) => void,
saveSession: () => Promise<void>; saveSession: () => Promise<void>;
saveStats: () => Promise<void>; saveStats: () => Promise<void>;

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();
}; };

27
src/utils/disabled.be.ts Normal file
View File

@@ -0,0 +1,27 @@
import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
async function getPendingEvals(userId: string): Promise<string[]> {
try {
const disabledStatsSessions = await db.collection("stats")
.distinct("session", {
"isDisabled": true,
"user": userId
});
const sessionsWithEvals = await db.collection("evaluation")
.distinct("session_id", {
"session_id": { $in: disabledStatsSessions },
"user": userId
});
return sessionsWithEvals;
} catch (error) {
console.error('Error fetching session IDs:', error);
throw error;
}
}
export default getPendingEvals;

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

@@ -5,6 +5,7 @@ import {
WritingExercise, WritingExercise,
} from "@/interfaces/exam"; } from "@/interfaces/exam";
import axios from "axios"; import axios from "axios";
import { v4 } from "uuid";
export const evaluateWritingAnswer = async ( export const evaluateWritingAnswer = async (
userId: string, userId: string,
@@ -13,7 +14,7 @@ export const evaluateWritingAnswer = async (
task: number, task: number,
solution: UserSolution, solution: UserSolution,
attachment?: string, attachment?: string,
): Promise<void> => { ): Promise<UserSolution> => {
await axios.post("/api/evaluate/writing", { await axios.post("/api/evaluate/writing", {
question: `${exercise.prompt}`.replaceAll("\n", ""), question: `${exercise.prompt}`.replaceAll("\n", ""),
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
@@ -23,6 +24,18 @@ export const evaluateWritingAnswer = async (
exerciseId: exercise.id, exerciseId: exercise.id,
attachment, attachment,
}); });
return {
...solution,
id: v4(),
score: {
correct: 0,
missing: 0,
total: 100,
},
solutions: [{id: exercise.id, solution: solution.solutions[0].solution}],
isDisabled: true,
};
}; };
export const evaluateSpeakingAnswer = async ( export const evaluateSpeakingAnswer = async (
@@ -31,12 +44,12 @@ export const evaluateSpeakingAnswer = async (
exercise: SpeakingExercise | InteractiveSpeakingExercise, exercise: SpeakingExercise | InteractiveSpeakingExercise,
solution: UserSolution, solution: UserSolution,
task: number, task: number,
): Promise<void> => { ): Promise<UserSolution> => {
switch (exercise?.type) { switch (exercise?.type) {
case "speaking": case "speaking":
await evaluateSpeakingExercise(userId, sessionId, exercise, solution); return await evaluateSpeakingExercise(userId, sessionId, exercise, solution);
case "interactiveSpeaking": case "interactiveSpeaking":
await evaluateInteractiveSpeakingExercise(userId, sessionId, exercise.id, solution, task); return await evaluateInteractiveSpeakingExercise(userId, sessionId, exercise.id, solution, task);
} }
}; };
@@ -50,7 +63,7 @@ const evaluateSpeakingExercise = async (
sessionId: string, sessionId: string,
exercise: SpeakingExercise, exercise: SpeakingExercise,
solution: UserSolution, solution: UserSolution,
): Promise<void> => { ): Promise<UserSolution> => {
const formData = new FormData(); const formData = new FormData();
const url = solution.solutions[0].solution.trim() as string; const url = solution.solutions[0].solution.trim() as string;
@@ -76,6 +89,17 @@ const evaluateSpeakingExercise = async (
}; };
await axios.post(`/api/evaluate/speaking`, formData, config); await axios.post(`/api/evaluate/speaking`, formData, config);
return {
...solution,
id: v4(),
score: {
correct: 0,
missing: 0,
total: 100,
},
solutions: [{id: exercise.id, solution: null}],
isDisabled: true,
};
}; };
const evaluateInteractiveSpeakingExercise = async ( const evaluateInteractiveSpeakingExercise = async (
@@ -84,7 +108,7 @@ const evaluateInteractiveSpeakingExercise = async (
exerciseId: string, exerciseId: string,
solution: UserSolution, solution: UserSolution,
task: number, task: number,
): Promise<void> => { ): Promise<UserSolution> => {
const formData = new FormData(); const formData = new FormData();
formData.append("userId", userId); formData.append("userId", userId);
formData.append("sessionId", sessionId); formData.append("sessionId", sessionId);
@@ -111,4 +135,15 @@ const evaluateInteractiveSpeakingExercise = async (
}; };
await axios.post(`/api/evaluate/interactiveSpeaking`, formData, config); await axios.post(`/api/evaluate/interactiveSpeaking`, formData, config);
return {
...solution,
id: v4(),
score: {
correct: 0,
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: null}],
isDisabled: true,
};
}; };

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

@@ -1,12 +1,140 @@
import {Stat} from "@/interfaces/user"; import { Stat } from "@/interfaces/user";
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 getStatsByUser = async (id: string) => await db.collection("stats").find<Stat>({user: id}).toArray(); export const getStatsByUser = async (id: string) => await db.collection("stats").find<Stat>({ user: id }).toArray();
export const getStatsByUsers = async (ids: string[]) => export const getStatsByUsers = async (ids: string[]) =>
await db await db
.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);
@@ -38,7 +38,7 @@ export async function searchUsers(searchInput?: string, limit = 50, page = 0, so
} }
} }
const [{ users, totalUsers }] = await db const [{ users, totalUsers }] = await db
.collection("users").aggregate([ .collection("users").aggregate([
{ {
@@ -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 {
const corporateAllowedEntities = findAllowedEntities(user, entities, 'view_corporates') ["view_students"]: allowedStudentEntities,
const masterCorporateAllowedEntities = findAllowedEntities(user, entities, 'view_mastercorporates') ["view_teachers"]: allowedTeacherEntities,
["view_corporates"]: allowedCorporateEntities,
["view_mastercorporates"]: allowedMasterCorporateEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
'view_corporates',
'view_mastercorporates',
]);
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
const teachers = await getEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" }) const students = await getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
const corporates = await getEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" }) const teachers = await getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
const masterCorporates = await getEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" }) 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 }
} }