Compare commits

..

9 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
56 changed files with 4451 additions and 2988 deletions

View File

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

View File

@@ -48,6 +48,15 @@ export default function AsyncSelect({
flat,
}: Props & (MultiProps | SingleProps)) {
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(() => {
if (document) setTarget(document.body);
@@ -77,7 +86,7 @@ export default function AsyncSelect({
filterOption={null}
loadingMessage={() => "Loading..."}
onInputChange={(inputValue) => {
loadOptions(inputValue);
setInputValue(inputValue);
}}
options={options}
value={value}

View File

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

View File

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

View File

@@ -14,8 +14,6 @@ import {
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import Button from "@/components/Low/Button";
import { sortByModuleName } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
@@ -24,7 +22,7 @@ import { Variant } from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/exam";
import moment from "moment";
import useStats from "../hooks/useStats";
interface Props {
user: User;
@@ -41,7 +39,21 @@ export default function Selection({ user, page, onStart }: Props) {
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
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 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" />
),
label: "Reading",
value: totalExamsByModule(stats, "reading"),
value: reading,
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" />
),
label: "Listening",
value: totalExamsByModule(stats, "listening"),
value: listening,
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" />
),
label: "Writing",
value: totalExamsByModule(stats, "writing"),
value: writing,
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" />
),
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
value: speaking,
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" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
value: level,
tooltip: "The amount of level exams performed.",
},
]}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
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" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<>
<div className="flex flex-col gap-4">
<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">
@@ -589,7 +588,7 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
</Button>
</div>
</div>
</Layout>
</>
</>
);
}

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
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" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<>
<div className="flex flex-col gap-4">
<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">
@@ -528,7 +527,7 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
</Button>
</div>
</div>
</Layout>
</>
</>
);
}

View File

@@ -1,15 +1,12 @@
import Layout from "@/components/High/Layout";
import Separator from "@/components/Low/Separator";
import AssignmentCard from "@/components/AssignmentCard";
import AssignmentView from "@/components/AssignmentView";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { CorporateUser, Group, User } from "@/interfaces/user";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { getUserCompanyName } from "@/resources/user";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import {
@@ -21,15 +18,13 @@ import {
} from "@/utils/assignments";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { groupBy } from "lodash";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { BsChevronLeft, BsPlus } from "react-icons/bs";
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" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<>
<div className="flex flex-col gap-4">
<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">
@@ -223,7 +218,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
))}
</div>
</section>
</Layout>
</>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,29 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, StudentUser, Type, User } from "@/interfaces/user";
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 { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel } from "@/utils/score";
import {
checkAccess,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import {
countAllowedUsers,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPencilSquare,
@@ -37,10 +38,10 @@ import { isAdmin } from "@/utils/users";
interface Props {
user: User;
students: StudentUser[]
latestStudents: User[]
latestTeachers: User[]
userCounts: { [key in Type]: number }
students: StudentUser[];
latestStudents: User[];
latestTeachers: User[];
userCounts: { [key in Type]: number };
entities: EntityWithRoles[];
assignmentsCount: number;
stats: Stat[];
@@ -48,38 +49,113 @@ interface Props {
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user || !user.isVerified) return redirect("/login")
const user = await requestUser(req, res);
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 entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDS
);
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students")
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
const {
["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
]);
const students =
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 allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
const entitiesIDS = mapBy(entities, "id") || [];
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);
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 totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities])
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 allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
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();
@@ -95,7 +171,7 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<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">
@@ -124,14 +200,16 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
value={groupsCount}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
<IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
{allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear}
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={allowedEntityStatistics.length}
@@ -139,7 +217,8 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
/>
)}
{allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear}
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={userCounts.student}
@@ -149,7 +228,11 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
@@ -164,31 +247,19 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
</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={latestStudents}
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
users={students.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
Object.keys(groupByExam(filterBy(stats, "user", a))).length
)}
title="Highest exam count students"
/>
</section>
</Layout>
</>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,20 @@
/* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input";
import Checkbox from "@/components/Low/Checkbox";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import { Entity, EntityWithRoles, Role } from "@/interfaces/entity";
import { GroupWithUsers, User } from "@/interfaces/user";
import { EntityWithRoles, Role } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntityWithRoles } from "@/utils/entities.be";
import { convertToUsers, getGroup } from "@/utils/groups.be";
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 { 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 axios from "axios";
import clsx from "clsx";
@@ -28,7 +24,7 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker";
import { CURRENCIES } from "@/resources/paypal";
@@ -37,11 +33,9 @@ import {
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsHash,
BsPerson,
BsPlus,
BsSquare,
BsStopwatchFill,
BsTag,
BsTrash,
@@ -344,7 +338,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<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"]]}
/>
</section>
</Layout>
</>
</>
);
}

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import Checkbox from "@/components/Low/Checkbox";
import Separator from "@/components/Low/Separator";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
@@ -152,7 +151,7 @@ interface Props {
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 [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" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
@@ -388,7 +387,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
</div>
</section>
</section>
</Layout>
</>
</>
);
}

View File

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

View File

@@ -1,19 +1,16 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip";
import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { Entity } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { redirect, serialize } from "@/utils";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName } from "@/utils/users";
import { getLinkedUsers, getUsers } from "@/utils/users.be";
import { getUsers } from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
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 { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({ 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" />
</Head>
<ToastContainer />
<Layout user={user}>
<>
<section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between">
<div className="flex items-center gap-2">
@@ -178,7 +174,7 @@ export default function Home({ user, users }: Props) {
</button>
))}
</section>
</Layout>
</>
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import AssignmentCard from "@/components/High/AssignmentCard";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import Separator from "@/components/Low/Separator";
import ProfileSummary from "@/components/ProfileSummary";
@@ -15,7 +14,10 @@ import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
import {
activeAssignmentFilter,
futureAssignmentFilter,
} from "@/utils/assignments";
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be";
@@ -45,66 +47,87 @@ interface Props {
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
const destination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`)
const user = await requestUser(req, res);
const destination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${destination}`);
if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/")
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } });
const sessions = await getSessionsByUser(user.id, 0, { "assignment.id": { $in: mapBy(assignments, 'id') } });
const assignments = await getAssignmentsByAssignee(user.id, {
archived: { $ne: true },
});
const sessions = await getSessionsByUser(user.id, 0, {
"assignment.id": { $in: mapBy(assignments, "id") },
});
const examIDs = uniqBy(
assignments.flatMap((a) =>
filterBy(a.exams, 'assignee', user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })),
filterBy(a.exams, "assignee", user.id).map(
(e: any) => ({
module: e.module,
id: e.id,
key: `${e.module}_${e.id}`,
})
)
),
"key",
"key"
);
const exams = await getExamsByIds(examIDs);
return { props: serialize({ user, entities, assignments, exams, sessions }) };
}, 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) {
const [isLoading, setIsLoading] = useState(false)
export default function OfficialExam({
user,
entities,
assignments,
sessions,
exams,
}: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch);
const reload = () => {
setIsLoading(true)
router.replace(router.asPath)
setTimeout(() => setIsLoading(false), 500)
}
setIsLoading(true);
router.replace(router.asPath);
setTimeout(() => setIsLoading(false), 500);
};
const startAssignment = (assignment: Assignment) => {
const assignmentExams = exams.filter(e => {
const exam = findBy(assignment.exams, 'id', e.id)
return !!exam && exam.module === e.module
})
const assignmentExams = exams.filter((e) => {
const exam = findBy(assignment.exams, "id", e.id);
return !!exam && exam.module === e.module;
});
if (assignmentExams.every((x) => !!x)) {
dispatch({
type: "INIT_EXAM", payload: {
type: "INIT_EXAM",
payload: {
exams: assignmentExams.sort(sortByModule),
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
assignment
}
})
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`);
modules: mapBy(assignmentExams.sort(sortByModule), "module"),
assignment,
},
});
router.push(
`/exam?assignment=${assignment.id}&destination=${destination}`
);
}
};
const loadSession = async (session: 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 () => {
@@ -113,12 +136,21 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
});
};
const studentAssignments = useMemo(() => [
...assignments.filter(activeAssignmentFilter), ...assignments.filter(futureAssignmentFilter)],
const studentAssignments = useMemo(
() => [
...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 (
<>
@@ -132,7 +164,7 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user} hideSidebar>
<>
{entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
@@ -147,29 +179,44 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
<section className="flex flex-col gap-1 md:gap-3">
<div
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">
<span className="text-mti-black text-lg font-bold">Assignments</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
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")}
/>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
{studentAssignments.length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{studentAssignments
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((a) =>
.map((a) => (
<AssignmentCard
key={a.id}
assignment={a}
user={user}
session={assignmentSessions.find(s => s.assignment?.id === a.id)}
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>
</Layout>
<Button
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 { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import useUser from "@/hooks/useUser";
import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import usePayments from "@/hooks/usePayments";
import usePaypalPayments from "@/hooks/usePaypalPayments";
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 { BsTrash } from "react-icons/bs";
import axios from "axios";
@@ -943,7 +941,7 @@ export default function PaymentRecord({ user, entities }: Props) {
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="gap-6">
<>
{getUserModal()}
<Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}>
<PaymentCreator
@@ -1248,7 +1246,7 @@ export default function PaymentRecord({ user, entities }: Props) {
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</Layout>
</>
)}
</>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Table from "@/components/High/Table";
import Checkbox from "@/components/Low/Checkbox";
import Separator from "@/components/Low/Separator";
@@ -8,16 +7,15 @@ import { Session } from "@/hooks/useSessions";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam";
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 { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getSessionsByAssignments, getSessionsByUser } from "@/utils/sessions.be";
import { getStatsByUsers } from "@/utils/stats.be";
import { findAllowedEntities } from "@/utils/permissions";
import { getSessionsByAssignments } from "@/utils/sessions.be";
import { isAdmin } from "@/utils/users";
import { getEntitiesUsers } from "@/utils/users.be";
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" />
</Head>
<Layout user={user}>
<>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
@@ -301,7 +299,7 @@ export default function Statistical({ user, students, entities, assignments, ses
isDownloadLoading={isDownloading}
/>
)}
</Layout>
</>
</>
)
}

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import { ITrainingContent, ITrainingTip } from "@/training/TrainingInterfaces";
import formatTip from "@/training/FormatTip";
import { Stat, User } from "@/interfaces/user";
import Head from "next/head";
import Layout from "@/components/High/Layout";
import { ToastContainer } from "react-toastify";
import { withIronSessionSsr } from "iron-session/next";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
@@ -149,7 +148,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
</Head>
<ToastContainer />
<Layout user={user}>
<>
{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">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
@@ -343,7 +342,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
</div>
)
)}
</Layout>
</>
</>
);
};

View File

@@ -4,7 +4,6 @@ import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
import {ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {useEffect, useState} from "react";
import clsx from "clsx";
@@ -22,10 +21,8 @@ import RecordFilter from "@/components/Medium/RecordFilter";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
@@ -179,7 +176,7 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
</Head>
<ToastContainer />
<Layout user={user}>
<>
{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">
<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 {sessionOptions} from "@/lib/session";
import useFilterStore from "@/stores/listFilterStore";
@@ -10,8 +8,7 @@ import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {withIronSessionSsr} from "iron-session/next";
import Head from "next/head";
import {useRouter} from "next/router";
import {useEffect} from "react";
import {BsArrowLeft, BsChevronLeft} from "react-icons/bs";
import { BsChevronLeft} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
import UserList from "../(admin)/Lists/UserList";
@@ -51,7 +48,7 @@ export default function UsersListPage({ user, type }: Props) {
</Head>
<ToastContainer />
<Layout user={user}>
<>
<UserList
user={user}
type={type}
@@ -70,7 +67,7 @@ export default function UsersListPage({ user, type }: Props) {
</div>
)}
/>
</Layout>
</>
</>
);
}

View File

@@ -1,27 +1,22 @@
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 { getUserCompanyName } from "@/resources/user";
import clsx from "clsx";
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 { withIronSessionSsr } from "iron-session/next";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { sessionOptions } from "@/lib/session";
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 { getParticipantGroups, getParticipantsGroups } from "@/utils/groups.be";
import { getParticipantsGroups } from "@/utils/groups.be";
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
import Head from "next/head";
import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { requestUser } from "@/utils/api";
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)
if (!user) return redirect("/login")
@@ -74,7 +69,7 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
</Head>
<ToastContainer />
<Layout user={user}>
<>
<div className="flex items-center gap-2">
<button
onClick={() => {
@@ -86,7 +81,7 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2>
</div>
<StudentPerformanceList items={performanceStudents} stats={stats} />
</Layout>
</>
</>
);
};

View File

@@ -1,6 +1,5 @@
import { User } from "@/interfaces/user";
import { IncomingMessage, ServerResponse } from "http";
import { IronSession } from "iron-session";
import { NextApiRequest, NextApiResponse } from "next";
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 });
};
export const getAssignmentsByAssignee = async (id: string, filter?: { [key in keyof Partial<Assignment>]: any }) => {
return await db
.collection("assignments")
.find<Assignment>({ assignees: id, ...(!filter ? {} : filter) })
export const getAssignmentsByAssignee = async (id: string, filter?: {}, projection?: {}, sort?: {}) => {
return await db.collection("assignments")
.aggregate([
{ $match: { assignees: id, ...(!filter ? {} : filter) } },
...(sort ? [{ $sort: sort }] : []),
...(projection ? [{ $project: projection }] : []),
])
.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) => {
return await db.collection("assignments").find<Assignment>({ assigner: id }).toArray();
};

View File

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

View File

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

View File

@@ -36,6 +36,43 @@ export function checkAccess(user: User, types: Type[], permissions?: PermissionT
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) {
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) {
if (isAdmin(user)) return true
const userEntity = findBy(user.entities, 'id', entity?.id)
if (!userEntity) return false

View File

@@ -10,3 +10,131 @@ export const getStatsByUsers = async (ids: string[]) =>
.collection("stats")
.find<Stat>({ user: { $in: ids } })
.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 { getEntity } from "./entities.be";
import { getRole } from "./roles.be";
import { findAllowedEntities } from "./permissions";
import { findAllowedEntities, groupAllowedEntitiesByPermissions } from "./permissions";
import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB);
@@ -75,6 +75,39 @@ export async function countUsers(filter?: object) {
.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> {
const user = await db.collection("users").findOne<User>({ id: id }, { projection: { _id: 0 } });
if (!user) return undefined;
@@ -91,8 +124,8 @@ export async function getUserWithEntity(id: string): Promise<WithEntities<User>
return { ...user, entities };
}
export async function getUser(id: string): Promise<User | undefined> {
const user = await db.collection("users").findOne<User>({ id: id }, { projection: { _id: 0 } });
export async function getUser(id: string, projection = {}): Promise<User | undefined> {
const user = await db.collection("users").findOne<User>({ id: id }, { projection: { _id: 0, ...projection } });
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 = {}) {
return await db
.collection("users")
.find<User>({ "entities.id": { $in: ids }, ...(filter || {}) }, projection)
.find<User>({ "entities.id": { $in: ids }, ...(filter || {}) }, { projection })
.limit(limit || 0)
.toArray();
}
@@ -199,29 +232,45 @@ export async function getUserBalance(user: User) {
}
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => {
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
const teachersAllowedEntities = findAllowedEntities(user, entities, 'view_teachers')
const corporateAllowedEntities = findAllowedEntities(user, entities, 'view_corporates')
const masterCorporateAllowedEntities = findAllowedEntities(user, entities, 'view_mastercorporates')
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
const teachers = await getEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" })
const corporates = await getEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" })
const masterCorporates = await getEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" })
const {
["view_students"]: allowedStudentEntities,
["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(allowedStudentEntities, 'id'), { type: "student" })
const teachers = await getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
const corporates = await getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
const masterCorporates = await getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
return [...students, ...teachers, ...corporates, ...masterCorporates]
}
export const countAllowedUsers = async (user: User, entities: EntityWithRoles[]) => {
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
const teachersAllowedEntities = findAllowedEntities(user, entities, 'view_teachers')
const corporateAllowedEntities = findAllowedEntities(user, entities, 'view_corporates')
const masterCorporateAllowedEntities = findAllowedEntities(user, entities, 'view_mastercorporates')
const {
["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
["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 teacher = await countEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" })
const corporate = await countEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" })
const mastercorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" })
const student = await countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
const teacher = await countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
const corporate = await countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
const mastercorporate = await countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
return { student, teacher, corporate, mastercorporate }
}