Merged with develop

This commit is contained in:
Tiago Ribeiro
2024-12-30 19:04:18 +00:00
37 changed files with 955 additions and 645 deletions

View File

@@ -1,8 +1,8 @@
import useEntities from "@/hooks/useEntities"; import useEntities from "@/hooks/useEntities";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Navbar from "../Navbar"; import Navbar from "../Navbar";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
@@ -23,19 +23,19 @@ export default function Layout({
user, user,
children, children,
className, className,
bgColor="bg-white", bgColor = "bg-white",
hideSidebar, hideSidebar,
navDisabled = false, navDisabled = false,
focusMode = false, focusMode = false,
onFocusLayerMouseEnter onFocusLayerMouseEnter
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
const {entities} = useEntities() const { entities } = useEntities()
return ( return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}> <main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<ToastContainer /> <ToastContainer />
{!hideSidebar && ( {!hideSidebar && user && (
<Navbar <Navbar
path={router.pathname} path={router.pathname}
user={user} user={user}
@@ -45,7 +45,7 @@ export default function Layout({
/> />
)} )}
<div className={clsx("h-full w-full flex gap-2")}> <div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && ( {!hideSidebar && user && (
<Sidebar <Sidebar
path={router.pathname} path={router.pathname}
navDisabled={navDisabled} navDisabled={navDisabled}

View File

@@ -13,6 +13,7 @@ interface Props {
disabled?: boolean; disabled?: boolean;
max?: number; max?: number;
min?: number; min?: number;
thin?: boolean
name: string; name: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -29,6 +30,7 @@ export default function Input({
className, className,
roundness = "full", roundness = "full",
disabled = false, disabled = false,
thin = false,
min, min,
onChange, onChange,
}: Props) { }: Props) {
@@ -95,9 +97,10 @@ export default function Input({
min={type === "number" ? (min ?? 0) : undefined} min={type === "number" ? (min ?? 0) : undefined}
placeholder={placeholder} placeholder={placeholder}
className={clsx( className={clsx(
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", "px-8 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed", "placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
roundness === "full" ? "rounded-full" : "rounded-xl", roundness === "full" ? "rounded-full" : "rounded-xl",
thin ? 'py-4' : 'py-6'
)} )}
required={required} required={required}
defaultValue={defaultValue} defaultValue={defaultValue}

View File

@@ -8,163 +8,166 @@ import useGroups from "@/hooks/useGroups";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
type TimeFilter = "months" | "weeks" | "days"; type TimeFilter = "months" | "weeks" | "days";
type Filter = TimeFilter | "assignments" | undefined; type Filter = TimeFilter | "assignments" | undefined;
interface Props { interface Props {
user: User; user: User;
entities: EntityWithRoles[] entities: EntityWithRoles[]
users: User[] users: User[]
filterState: { filterState: {
filter: Filter, filter: Filter,
setFilter: React.Dispatch<React.SetStateAction<Filter>> setFilter: React.Dispatch<React.SetStateAction<Filter>>
}, },
assignments?: boolean; assignments?: boolean;
children?: ReactNode children?: ReactNode
} }
const defaultSelectableCorporate = { const defaultSelectableCorporate = {
value: "", value: "",
label: "All", label: "All",
}; };
const RecordFilter: React.FC<Props> = ({ const RecordFilter: React.FC<Props> = ({
user, user,
entities, entities,
users, users,
filterState, filterState,
assignments = true, assignments = true,
children children
}) => { }) => {
const { filter, setFilter } = filterState; const { filter, setFilter } = filterState;
const [entity, setEntity] = useState<string>() const [entity, setEntity] = useState<string>()
const [, setStatsUserId] = useRecordStore((state) => [ const [, setStatsUserId] = useRecordStore((state) => [
state.selectedUser, state.selectedUser,
state.setSelectedUser state.setSelectedUser
]); ]);
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity]) const allowedViewEntities = useAllowedEntities(user, entities, 'view_student_record')
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]) const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity])
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id])
setFilter((prev) => (prev === value ? undefined : value));
};
return ( const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center"> setFilter((prev) => (prev === value ? undefined : value));
<div className="xl:w-3/4 flex gap-2"> };
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
<>
<div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select return (
options={entities.map((e) => ({value: e.id, label: e.label}))} <div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
onChange={(value) => setEntity(value?.value || undefined)} <div className="xl:w-3/4 flex gap-2">
isClearable {checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
styles={{ <>
menuPortal: (base) => ({ ...base, zIndex: 9999 }), <div className="flex flex-col gap-2 w-full">
option: (styles, state) => ({ <label className="font-normal text-base text-mti-gray-dim">Entity</label>
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}} />
</div>
<div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select <Select
options={entityUsers.map((x) => ({ options={allowedViewEntities.map((e) => ({ value: e.id, label: e.label }))}
value: x.id, onChange={(value) => setEntity(value?.value || undefined)}
label: `${x.name} - ${x.email}`, isClearable
}))} styles={{
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} menuPortal: (base) => ({ ...base, zIndex: 9999 }),
onChange={(value) => setStatsUserId(value?.value!)} option: (styles, state) => ({
styles={{ ...styles,
menuPortal: (base) => ({ ...base, zIndex: 9999 }), backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
option: (styles, state) => ({ color: state.isFocused ? "black" : styles.color,
...styles, }),
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", }} />
color: state.isFocused ? "black" : styles.color, </div>
}), <div className="flex flex-col gap-2 w-full">
}} <label className="font-normal text-base text-mti-gray-dim">User</label>
/>
</div>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && !children && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select <Select
options={users options={entityUsers.map((x) => ({
.map((x) => ({ value: x.id,
value: x.id, label: `${x.name} - ${x.email}`,
label: `${x.name} - ${x.email}`, }))}
}))} defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} onChange={(value) => setStatsUserId(value?.value!)}
onChange={(value) => setStatsUserId(value?.value!)} styles={{
styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }),
menuPortal: (base) => ({ ...base, zIndex: 9999 }), option: (styles, state) => ({
option: (styles, state) => ({ ...styles,
...styles, backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", color: state.isFocused ? "black" : styles.color,
color: state.isFocused ? "black" : styles.color, }),
}), }}
}} />
/> </div>
</div> </>
)} )}
{children} {(user.type === "corporate" || user.type === "teacher") && !children && (
</div> <div className="flex flex-col gap-2">
<div className="flex gap-4 w-full justify-center xl:justify-end"> <label className="font-normal text-base text-mti-gray-dim">User</label>
{assignments && (
<button <Select
className={clsx( options={users
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", .map((x) => ({
"transition duration-300 ease-in-out", value: x.id,
filter === "assignments" && "!bg-mti-purple-light !text-white", label: `${x.name} - ${x.email}`,
)} }))}
onClick={() => toggleFilter("assignments")}> defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
Assignments onChange={(value) => setStatsUserId(value?.value!)}
</button> styles={{
)} menuPortal: (base) => ({ ...base, zIndex: 9999 }),
<button option: (styles, state) => ({
className={clsx( ...styles,
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
"transition duration-300 ease-in-out", color: state.isFocused ? "black" : styles.color,
filter === "months" && "!bg-mti-purple-light !text-white", }),
)} }}
onClick={() => toggleFilter("months")}> />
Last month </div>
</button> )}
<button {children}
className={clsx( </div>
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", <div className="flex gap-4 w-full justify-center xl:justify-end">
"transition duration-300 ease-in-out", {assignments && (
filter === "weeks" && "!bg-mti-purple-light !text-white", <button
)} className={clsx(
onClick={() => toggleFilter("weeks")}> "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
Last week "transition duration-300 ease-in-out",
</button> filter === "assignments" && "!bg-mti-purple-light !text-white",
<button )}
className={clsx( onClick={() => toggleFilter("assignments")}>
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", Assignments
"transition duration-300 ease-in-out", </button>
filter === "days" && "!bg-mti-purple-light !text-white", )}
)} <button
onClick={() => toggleFilter("days")}> className={clsx(
Last day "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
</button> "transition duration-300 ease-in-out",
</div> filter === "months" && "!bg-mti-purple-light !text-white",
</div> )}
); onClick={() => toggleFilter("months")}>
Last month
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div>
);
} }
export default RecordFilter; export default RecordFilter;

View File

@@ -82,7 +82,7 @@ interface StatsGridItemProps {
selectedTrainingExams?: string[]; selectedTrainingExams?: string[];
maxTrainingExams?: number; maxTrainingExams?: number;
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>; setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode; renderPdfIcon?: (session: string, color: string, textColor: string) => React.ReactNode;
} }
const StatsGridItem: React.FC<StatsGridItemProps> = ({ const StatsGridItem: React.FC<StatsGridItemProps> = ({
@@ -236,7 +236,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
{renderLevelScore()} {renderLevelScore()}
</span> </span>
)} )}
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)} {shouldRenderPDFIcon() && renderPdfIcon && renderPdfIcon(session, textColor, textColor)}
</div> </div>
{examNumber === undefined ? ( {examNumber === undefined ? (
<> <>

View File

@@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa
{ {
module: "reading", module: "reading",
icon: () => <BsBook className="h-4 w-4 text-white" />, icon: () => <BsBook className="h-4 w-4 text-white" />,
achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9, achieved: user?.levels?.reading || 0 >= user?.desiredLevels?.reading || 9,
}, },
{ {
module: "listening", module: "listening",
icon: () => <BsHeadphones className="h-4 w-4 text-white" />, icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9, achieved: user?.levels?.listening || 0 >= user?.desiredLevels?.listening || 9,
}, },
{ {
module: "writing", module: "writing",
icon: () => <BsPen className="h-4 w-4 text-white" />, icon: () => <BsPen className="h-4 w-4 text-white" />,
achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9, achieved: user?.levels?.writing || 0 >= user?.desiredLevels?.writing || 9,
}, },
{ {
module: "speaking", module: "speaking",
icon: () => <BsMegaphone className="h-4 w-4 text-white" />, icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9, achieved: user?.levels?.speaking || 0 >= user?.desiredLevels?.speaking || 9,
}, },
{ {
module: "level", module: "level",
icon: () => <BsClipboard className="h-4 w-4 text-white" />, icon: () => <BsClipboard className="h-4 w-4 text-white" />,
achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9, achieved: user?.levels?.level || 0 >= user?.desiredLevels?.level || 9,
}, },
]; ];

View File

@@ -1,15 +1,17 @@
import {PaymentIntention} from "@/interfaces/paymob"; import { Entity } from "@/interfaces/entity";
import {DurationUnit} from "@/interfaces/paypal"; import { PaymentIntention } from "@/interfaces/paymob";
import {User} from "@/interfaces/user"; import { DurationUnit } from "@/interfaces/paypal";
import { User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {useState} from "react"; import { useState } from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Input from "./Low/Input"; import Input from "./Low/Input";
import Modal from "./Modal"; import Modal from "./Modal";
interface Props { interface Props {
user: User; user: User;
entity?: Entity
currency: string; currency: string;
price: number; price: number;
setIsPaymentLoading: (v: boolean) => void; setIsPaymentLoading: (v: boolean) => void;
@@ -18,7 +20,7 @@ interface Props {
onSuccess: (duration: number, duration_unit: DurationUnit) => void; onSuccess: (duration: number, duration_unit: DurationUnit) => void;
} }
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { export default function PaymobPayment({ user, entity, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -56,10 +58,11 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
userID: user.id, userID: user.id,
duration, duration,
duration_unit, duration_unit,
entity: entity?.id
}, },
}; };
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); const response = await axios.post<{ iframeURL: string }>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL); router.push(response.data.iframeURL);
} catch (error) { } catch (error) {

View File

@@ -25,6 +25,7 @@ import ProgressButtons from "../components/ProgressButtons";
import useExamNavigation from "../Navigation/useExamNavigation"; import useExamNavigation from "../Navigation/useExamNavigation";
import { calculateExerciseIndex } from "../utils/calculateExerciseIndex"; import { calculateExerciseIndex } from "../utils/calculateExerciseIndex";
import { defaultExamUserSolutions } from "@/utils/exams"; import { defaultExamUserSolutions } from "@/utils/exams";
import PracticeModal from "@/components/PracticeModal";
const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => { const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
@@ -66,7 +67,6 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
const [continueAnyways, setContinueAnyways] = useState(false); const [continueAnyways, setContinueAnyways] = useState(false);
const [textRender, setTextRender] = useState(false); const [textRender, setTextRender] = useState(false);
const [questionModalKwargs, setQuestionModalKwargs] = useState<{ const [questionModalKwargs, setQuestionModalKwargs] = useState<{
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined; type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
}>({ }>({
@@ -101,6 +101,14 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
} }
); );
const hasPractice = useMemo(() => {
if (partIndex > -1 && partIndex < exam.parts.length && !showPartDivider) {
console.log(exam.parts[partIndex].exercises.some(e => e.isPractice))
return exam.parts[partIndex].exercises.some(e => e.isPractice)
}
return false
}, [partIndex, showPartDivider, exam.parts])
const registerSolution = useCallback((updateSolution: () => UserSolution) => { const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution; userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true); setSolutionWasUpdated(true);
@@ -337,6 +345,7 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
return ( return (
<> <>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}> <div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<PracticeModal key={`${partIndex}_${showPartDivider}`} open={hasPractice} />
<Modal <Modal
className={"!w-2/6 !p-8"} className={"!w-2/6 !p-8"}
titleClassName={"font-bold text-3xl text-mti-rose-light"} titleClassName={"font-bold text-3xl text-mti-rose-light"}
@@ -361,7 +370,7 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
(!showPartDivider && !startNow) && (!showPartDivider && !startNow) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions || preview} standalone={true} /> <Timer minTimer={exam.minTimer} disableTimer={showSolutions || preview} standalone={true} />
} }
{(showPartDivider || (startNow && partIndex === 0)) ? {(showPartDivider || (startNow && partIndex === 0)) ?
<PartDivider <PartDivider
module="level" module="level"
sectionLabel="Part" sectionLabel="Part"

View File

@@ -4,6 +4,11 @@ export interface Entity {
id: string; id: string;
label: string; label: string;
licenses: number; licenses: number;
expiryDate?: Date | null
payment?: {
currency: string
price: number
}
} }
export interface Role { export interface Role {

View File

@@ -5,7 +5,7 @@ export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | Le
export type Variant = "full" | "partial"; export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied"; export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = BasicDifficulty | CEFRLevels; export type Difficulty = BasicDifficulty | CEFRLevels;
// Left easy, medium and hard to support older exam versions // Left easy, medium and hard to support older exam versions
export type BasicDifficulty = "easy" | "medium" | "hard"; export type BasicDifficulty = "easy" | "medium" | "hard";
@@ -20,6 +20,7 @@ export interface ExamBase {
variant?: Variant; variant?: Variant;
difficulty?: Difficulty; difficulty?: Difficulty;
owners?: string[]; owners?: string[];
entities?: string[]
shuffle?: boolean; shuffle?: boolean;
createdBy?: string; // option as it has been added later createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later createdAt?: string; // option as it has been added later
@@ -52,7 +53,7 @@ export interface LevelExam extends ExamBase {
} }
export interface LevelPart extends Section { export interface LevelPart extends Section {
// to support old exams that have reading passage mc on context // to support old exams that have reading passage mc on context
context?: string; context?: string;
exercises: Exercise[]; exercises: Exercise[];
audio?: { audio?: {

View File

@@ -28,7 +28,7 @@ interface Customer {
extras: IntentionExtras; extras: IntentionExtras;
} }
type IntentionExtras = {[key: string]: string | number}; type IntentionExtras = { [key: string]: string | number | undefined };
export interface IntentionResult { export interface IntentionResult {
payment_keys: PaymentKeysItem[]; payment_keys: PaymentKeysItem[];

View File

@@ -32,6 +32,7 @@ export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment { export interface Payment {
id: string; id: string;
corporate: string; corporate: string;
entity?: string
agent?: string; agent?: string;
agentCommission: number; agentCommission: number;
agentValue: number; agentValue: number;

View File

@@ -28,6 +28,7 @@ export interface BasicUser {
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
studentID?: string; studentID?: string;
averageLevel?: number
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];

View File

@@ -24,6 +24,7 @@ import { WithLabeledEntities } from "@/interfaces/entity";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
import useEntities from "@/hooks/useEntities"; import useEntities from "@/hooks/useEntities";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { findAllowedEntities } from "@/utils/permissions";
const columnHelper = createColumnHelper<WithLabeledEntities<User>>(); const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
const searchFields = [["name"], ["email"], ["entities", ""]]; const searchFields = [["name"], ["email"], ["entities", ""]];
@@ -45,8 +46,6 @@ export default function UserList({
const { users, reload } = useEntitiesUsers(type) const { users, reload } = useEntitiesUsers(type)
const { entities } = useEntities() const { entities } = useEntities()
const { balance } = useUserBalance();
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type]) const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type])
const entitiesViewStudents = useAllowedEntities(user, entities, "view_students") const entitiesViewStudents = useAllowedEntities(user, entities, "view_students")
@@ -65,6 +64,8 @@ export default function UserList({
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates") const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates")
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates") const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates")
const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list")
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -342,7 +343,10 @@ export default function UserList({
]; ];
const downloadExcel = (rows: WithLabeledEntities<User>[]) => { const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
const csv = exportListToExcel(rows); if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.")
const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e)))
const csv = exportListToExcel(allowedRows);
const element = document.createElement("a"); const element = document.createElement("a");
const file = new Blob([csv], { type: "text/csv" }); const file = new Blob([csv], { type: "text/csv" });
@@ -437,7 +441,7 @@ export default function UserList({
data={displayUsers} data={displayUsers}
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any} columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
searchFields={searchFields} searchFields={searchFields}
onDownload={downloadExcel} onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined}
/> />
</div> </div>
</> </>

View File

@@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize } from "lodash"; import { capitalize, sortBy } from "lodash";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import { BsArrowRepeat } from "react-icons/bs"; import { BsArrowRepeat } from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
@@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment"; import PaymobPayment from "@/components/PaymobPayment";
import moment from "moment"; import moment from "moment";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Discount, Package } from "@/interfaces/paypal";
import { isAdmin } from "@/utils/users";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import Select from "@/components/Low/Select";
interface Props { interface Props {
user: User; user: User
discounts: Discount[]
packages: Package[]
entities: EntityWithRoles[] entities: EntityWithRoles[]
hasExpired?: boolean; hasExpired?: boolean;
reload: () => void; reload: () => void;
} }
export default function PaymentDue({ user, hasExpired = false, reload }: Props) { export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState(0); const [entity, setEntity] = useState<EntityWithRoles>()
const router = useRouter(); const router = useRouter();
const { packages } = usePackages();
const { discounts } = useDiscounts();
const { users } = useUsers(); const { users } = useUsers();
const { groups } = useGroups({});
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id });
useEffect(() => { const isIndividual = useMemo(() => {
const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`)); if (isAdmin(user)) return false;
if (userDiscounts.length === 0) return;
const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return;
setAppliedDiscount(biggestDiscount.percentage);
}, [discounts, user]);
const isIndividual = () => {
if (user?.type === "developer") return true;
if (user?.type !== "student") return false; if (user?.type !== "student") return false;
const userGroups = groups.filter((g) => g.participants.includes(user?.id));
if (userGroups.length === 0) return true; return user.entities.length === 0
}, [user])
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t); const appliedDiscount = useMemo(() => {
return userGroupsAdminTypes.every((t) => t !== "corporate"); const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift();
};
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment())))
return 0;
return biggestDiscount.percentage
}, [discounts])
const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity')
useEffect(() => {
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0])
}, [entitiesThatCanBePaid])
return ( return (
<> <>
@@ -74,169 +78,185 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div> </div>
</div> </div>
)} )}
{user ? ( <Layout user={user} navDisabled={hasExpired}>
<Layout user={user} navDisabled={hasExpired}> {invites.length > 0 && (
{invites.length > 0 && ( <section className="flex flex-col gap-1 md:gap-3">
<section className="flex flex-col gap-1 md:gap-3"> <div className="flex items-center gap-4">
<div className="flex items-center gap-4"> <div
<div onClick={reloadInvites}
onClick={reloadInvites} className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> <span className="text-mti-black text-lg font-bold">Invites</span>
<span className="text-mti-black text-lg font-bold">Invites</span> <BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
</div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> </div>
{invites.map((invite) => ( <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
<InviteCard {invites.map((invite) => (
key={invite.id} <InviteCard
invite={invite} key={invite.id}
users={users} invite={invite}
reload={() => { users={users}
reloadInvites(); reload={() => {
router.reload(); reloadInvites();
}} router.reload();
/> }}
))} />
</span> ))}
</section> </span>
)} </section>
)}
<div className="flex w-full flex-col items-center justify-center gap-4 text-center"> <div className="flex w-full flex-col items-center justify-center gap-4 text-center">
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>} {hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
{isIndividual() && ( {isIndividual && (
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll"> <div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below: To add to your use of EnCoach, please purchase one of the time packages available below:
</span> </span>
<div className="flex w-full flex-wrap justify-center gap-8"> <div className="flex w-full flex-wrap justify-center gap-8">
{packages.map((p) => ( {packages.map((p) => (
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> <div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start">
<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,
)}
</span>
</div>
<div className="flex w-full flex-col items-start gap-2">
{appliedDiscount === 0 && (
<span className="text-2xl">
{p.price} {p.currency}
</span>
)}
{appliedDiscount > 0 && (
<div className="flex items-center gap-2">
<span className="text-2xl line-through">
{p.price} {p.currency}
</span>
<span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
</span>
</div>
)}
<PaymobPayment
user={user}
setIsPaymentLoading={setIsLoading}
onSuccess={() => {
setTimeout(reload, 500);
}}
currency={p.currency}
duration={p.duration}
duration_unit={p.duration_unit}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
/>
</div>
<div className="flex flex-col items-start gap-1">
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li>
<li>- Gain insights into your weaknesses and strengths</li>
<li>- Allow yourself to correctly prepare for the exam</li>
</ul>
</div>
</div>
))}
</div>
</div>
)}
{!isIndividual() &&
(user?.type === "corporate" || user?.type === "mastercorporate") &&
user?.corporateInformation.payment && (
<div className="flex flex-col items-center">
<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:
</span>
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
EnCoach - {12} Months EnCoach - {p.duration}{" "}
{capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
)}
</span> </span>
</div> </div>
<div className="flex w-full flex-col items-start gap-2"> <div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl"> {appliedDiscount === 0 && (
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency} <span className="text-2xl">
</span> {p.price} {p.currency}
</span>
)}
{appliedDiscount > 0 && (
<div className="flex items-center gap-2">
<span className="text-2xl line-through">
{p.price} {p.currency}
</span>
<span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
</span>
</div>
)}
<PaymobPayment <PaymobPayment
user={user} user={user}
setIsPaymentLoading={setIsLoading} setIsPaymentLoading={setIsLoading}
currency={user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value}
duration={12}
duration_unit="months"
onSuccess={() => { onSuccess={() => {
setIsLoading(false);
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
currency={p.currency}
duration={p.duration}
duration_unit={p.duration_unit}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li> <li>- Train your abilities for the IELTS exam</li>
- Allow a total of 0 students and teachers to use EnCoach <li>- Gain insights into your weaknesses and strengths</li>
</li> <li>- Allow yourself to correctly prepare for the exam</li>
<li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li>
<li>- Allow them to correctly prepare for the exam</li>
</ul> </ul>
</div> </div>
</div> </div>
))}
</div>
</div>
)}
{!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>
<Select
defaultValue={{ value: entity?.id, label: entity?.label }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
className="!w-full max-w-[400px] self-center"
/>
</div> </div>
)}
{!isIndividual() && !(user?.type === "corporate" || user?.type === "mastercorporate") && (
<div className="flex flex-col items-center">
<span className="max-w-lg"> <span className="max-w-lg">
You are not the person in charge of your time credits, please contact your administrator about this situation. 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="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="text-xl font-semibold">
EnCoach - {12} Months
</span>
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl">
{entity.payment.price} {entity.payment.currency}
</span>
<PaymobPayment
user={user}
setIsPaymentLoading={setIsLoading}
entity={entity}
currency={entity.payment.currency}
price={entity.payment.price}
duration={12}
duration_unit="months"
onSuccess={() => {
setIsLoading(false);
setTimeout(reload, 500);
}}
/>
</div>
<div className="flex flex-col items-start gap-1">
<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
</li>
<li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li>
<li>- Allow them to correctly prepare for the exam</li>
</ul>
</div>
</div>
</div>
)}
{!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.
</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.
</span>
</div>
)}
{!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>
<Select
defaultValue={{ value: entity?.id || "", label: entity?.label || "" }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
className="!w-full max-w-[400px] self-center"
/>
</div>
<span className="max-w-lg">
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users
you desire and your expected monthly duration.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your Please try again later or contact your agent or an admin, thank you for your patience.
patience.
</span> </span>
</div> </div>
)} )}
{!isIndividual() && </div>
(user?.type === "corporate" || user?.type === "mastercorporate") && </Layout>
!user.corporateInformation.payment && (
<div className="flex flex-col items-center">
<span className="max-w-lg">
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users
you desire and your expected monthly duration.
</span>
<span className="max-w-lg">
Please try again later or contact your agent or an admin, thank you for your patience.
</span>
</div>
)}
</div>
</Layout>
) : (
<div />
)}
</> </>
); );
} }

View File

@@ -6,9 +6,11 @@ import { deleteEntity, getEntity, getEntityWithRoles } from "@/utils/entities.be
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { Entity } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { doesEntityAllow } from "@/utils/permissions"; import { doesEntityAllow } from "@/utils/permissions";
import { getUser } from "@/utils/users.be"; import { getEntityUsers, getUser } from "@/utils/users.be";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { filterBy, mapBy } from "@/utils";
import { User } from "@/interfaces/user";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -66,5 +68,27 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ ok: entity.acknowledged }); return res.status(200).json({ ok: entity.acknowledged });
} }
if (req.body.payment) {
const entity = await db.collection<Entity>("entities").updateOne({ id }, { $set: { payment: req.body.payment } });
return res.status(200).json({ ok: entity.acknowledged });
}
if (req.body.expiryDate !== undefined) {
const entity = await getEntity(id)
const result = await db.collection<Entity>("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } });
const users = await getEntityUsers(id, 0, {
subscriptionExpirationDate: entity?.expiryDate,
$and: [
{ type: { $ne: "admin" } },
{ type: { $ne: "developer" } },
]
})
await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
return res.status(200).json({ ok: result.acknowledged });
}
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
} }

View File

@@ -7,6 +7,9 @@ import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be"; import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getUserCorporate } from "@/utils/groups.be"; import { getUserCorporate } from "@/utils/groups.be";
import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users";
import { mapBy } from "@/utils";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -37,25 +40,20 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
} }
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
return;
}
const { module } = req.query as { module: string }; const { module } = req.query as { module: string };
const corporate = await getUserCorporate(req.session.user.id);
const session = client.startSession(); const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
try { try {
const exam = { const exam = {
...req.body, ...req.body,
module: module, module: module,
owners: [ entities,
...(["mastercorporate", "corporate"].includes(req.session.user.type) ? [req.session.user.id] : []), createdBy: user.id,
...(!!corporate ? [corporate.id] : []),
],
createdBy: req.session.user.id,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };

View File

@@ -1,6 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { CorporateUser, User } from "@/interfaces/user";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { getLinkedUsers } from "@/utils/users.be";
import { uniqBy } from "lodash";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { v4 } from "uuid";
import fs from 'fs'
import { findBy, mapBy } from "@/utils";
import { addUsersToEntity, getEntitiesWithRoles } from "@/utils/entities.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);

View File

@@ -1,15 +1,19 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {Group, User} from "@/interfaces/user"; import { Group, User } from "@/interfaces/user";
import {DurationUnit, Package, Payment} from "@/interfaces/paypal"; import { DurationUnit, Package, Payment } from "@/interfaces/paypal";
import {v4} from "uuid"; import { v4 } from "uuid";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import axios from "axios"; import axios from "axios";
import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob"; import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob";
import moment from "moment"; import moment from "moment";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { getEntity } from "@/utils/entities.be";
import { Entity } from "@/interfaces/entity";
import { getEntityUsers } from "@/utils/users.be";
import { mapBy } from "@/utils";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -22,21 +26,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const authToken = await authenticatePaymob(); const authToken = await authenticatePaymob();
console.log("WEBHOOK: ", transactionResult); console.log("WEBHOOK: ", transactionResult);
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false });
if (!transactionResult.transaction.success) return res.status(400).json({ok: false}); if (!transactionResult.transaction.success) return res.status(400).json({ ok: false });
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as { const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as {
userID: string; userID: string;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
entity: string
}; };
const user = await db.collection("users").findOne<User>({ id: userID as string }); const user = await db.collection("users").findOne<User>({ id: userID as string });
if (!user || !duration || !duration_unit) return res.status(404).json({ok: false}); if (!user || !duration || !duration_unit) return res.status(404).json({ ok: false });
const subscriptionExpirationDate = user.subscriptionExpirationDate; const subscriptionExpirationDate = user.subscriptionExpirationDate;
if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); if (!subscriptionExpirationDate) return res.status(200).json({ ok: false });
const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment();
@@ -44,8 +49,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
await db.collection("users").updateOne( await db.collection("users").updateOne(
{ id: userID as string }, { id: userID as string },
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } { $set: { subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active" } }
); );
await db.collection("paypalpayments").insertOne({ await db.collection("paypalpayments").insertOne({
id: v4(), id: v4(),
@@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
value: transactionResult.transaction.amount_cents / 1000, value: transactionResult.transaction.amount_cents / 1000,
}); });
if (user.type === "corporate") { if (entityID) {
const groups = await db.collection("groups").find<Group>({ admin: user.id }).toArray(); const entity = await getEntity(entityID)
await db.collection<Entity>("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } });
const participants = (await Promise.all( const users = await getEntityUsers(entityID, 0, {
groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})), subscriptionExpirationDate: entity?.expiryDate,
)) as User[]; $and: [
const sameExpiryDateParticipants = participants.filter( { type: { $ne: "admin" } },
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled", { type: { $ne: "developer" } },
); ]
})
for (const participant of sameExpiryDateParticipants) { await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
await db.collection("users").updateOne(
{ id: participant.id },
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
);
}
} }
res.status(200).json({ res.status(200).json({
@@ -84,19 +86,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
} }
const authenticatePaymob = async () => { const authenticatePaymob = async () => {
const response = await axios.post<{token: string}>( const response = await axios.post<{ token: string }>(
"https://oman.paymob.com/api/auth/tokens", "https://oman.paymob.com/api/auth/tokens",
{ {
api_key: process.env.PAYMOB_API_KEY, api_key: process.env.PAYMOB_API_KEY,
}, },
{headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}}, { headers: { Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}` } },
); );
return response.data.token; return response.data.token;
}; };
const checkTransaction = async (token: string, orderID: number) => { const checkTransaction = async (token: string, orderID: number) => {
const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", {auth_token: token, order_id: orderID}); const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", { auth_token: token, order_id: orderID });
return response.status === 200; return response.status === 200;
}; };

View File

@@ -1,15 +1,15 @@
import {MODULES} from "@/constants/ielts"; import { MODULES } from "@/constants/ielts";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Stat, User} from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {calculateBandScore} from "@/utils/score"; import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import {groupByModule, groupBySession} from "@/utils/stats"; import { groupByModule, groupBySession } from "@/utils/stats";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {groupBy} from "lodash"; import { groupBy } from "lodash";
import {NextApiRequest, NextApiResponse} from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -29,8 +29,8 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
const stats = await db.collection("stats").find<Stat>({ user: user.id }).toArray(); const stats = await db.collection("stats").find<Stat>({ user: user.id }).toArray();
const groupedStats = groupBySession(stats); const groupedStats = groupBySession(stats);
const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => { const sessionLevels: { [key in Module]: { correct: number; total: number } }[] = Object.keys(groupedStats).map((key) => {
const sessionStats = groupedStats[key].map((stat) => ({module: stat.module, correct: stat.score.correct, total: stat.score.total})); const sessionStats = groupedStats[key].map((stat) => ({ module: stat.module, correct: stat.score.correct, total: stat.score.total }));
const sessionLevels = { const sessionLevels = {
reading: { reading: {
correct: 0, correct: 0,
@@ -59,8 +59,8 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
if (moduleStats.length === 0) return; if (moduleStats.length === 0) return;
const moduleScore = moduleStats.reduce( const moduleScore = moduleStats.reduce(
(accumulator, current) => ({correct: accumulator.correct + current.correct, total: accumulator.total + current.total}), (accumulator, current) => ({ correct: accumulator.correct + current.correct, total: accumulator.total + current.total }),
{correct: 0, total: 0}, { correct: 0, total: 0 },
); );
sessionLevels[module] = moduleScore; sessionLevels[module] = moduleScore;
@@ -72,24 +72,24 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
const readingLevel = sessionLevels const readingLevel = sessionLevels
.map((x) => x.reading) .map((x) => x.reading)
.filter((x) => x.total > 0) .filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 });
const listeningLevel = sessionLevels const listeningLevel = sessionLevels
.map((x) => x.listening) .map((x) => x.listening)
.filter((x) => x.total > 0) .filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 });
const writingLevel = sessionLevels const writingLevel = sessionLevels
.map((x) => x.writing) .map((x) => x.writing)
.filter((x) => x.total > 0) .filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 });
const speakingLevel = sessionLevels const speakingLevel = sessionLevels
.map((x) => x.speaking) .map((x) => x.speaking)
.filter((x) => x.total > 0) .filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 });
const levelLevel = sessionLevels const levelLevel = sessionLevels
.map((x) => x.level) .map((x) => x.level)
.filter((x) => x.total > 0) .filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 });
const levels = { const levels = {
@@ -100,12 +100,14 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", user.focus), level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", user.focus),
}; };
const averageLevel = calculateAverageLevel(levels)
await db.collection("users").updateOne( await db.collection("users").updateOne(
{ id: user.id}, { id: user.id },
{ $set: {levels} } { $set: { levels, averageLevel } }
); );
res.status(200).json({ok: true}); res.status(200).json({ ok: true });
} else { } else {
res.status(401).json(undefined); res.status(401).json(undefined);
} }

View File

@@ -139,7 +139,6 @@ export default function Dashboard({
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPersonFillGear} <IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
@@ -149,7 +148,7 @@ export default function Dashboard({
<IconCard Icon={BsPersonFillGear} <IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={students.length} value={usersCount.student}
color="purple" color="purple"
/> />
<IconCard <IconCard

View File

@@ -3,18 +3,18 @@ import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, Type, User } from "@/interfaces/user"; import { Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments } from "@/utils/assignments.be"; import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities } from "@/utils/groups.be"; import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel } from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { countAllowedUsers, filterAllowedUsers } from "@/utils/users.be"; import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
@@ -37,7 +37,9 @@ import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
users: User[]; students: StudentUser[]
latestStudents: User[]
latestTeachers: User[]
userCounts: { [key in Type]: number } userCounts: { [key in Type]: number }
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignmentsCount: number; assignmentsCount: number;
@@ -53,24 +55,28 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const users = await filterAllowedUsers(user, entities)
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students")
const allowedTeacherEntities = findAllowedEntities(user, entities, "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 userCounts = await countAllowedUsers(user, entities) const userCounts = await countAllowedUsers(user, entities)
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } }); const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
const groupsCount = await countGroupsByEntities(mapBy(entities, "id")); const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
const stats = await getStatsByUsers(users.map((u) => u.id)); return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) };
return { props: serialize({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }) };
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }: Props) { export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const totalCount = useMemo(() => const totalCount = useMemo(() =>
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + curr.licenses, 0), [entities]) const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities])
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
@@ -124,7 +130,6 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
value={`${entities.length} - ${totalCount}/${totalLicenses}`} value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple" color="purple"
/> />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
{allowedEntityStatistics.length > 0 && ( {allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
@@ -160,15 +165,15 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={latestStudents}
title="Latest Students" title="Latest Students"
/> />
<UserDisplayList <UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={latestTeachers}
title="Latest Teachers" title="Latest Teachers"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList

View File

@@ -4,7 +4,7 @@ import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { Group, Stat, Type, User } from "@/interfaces/user"; import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
@@ -51,7 +51,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer"])) return redirect("/") if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
const students = await getUsers({ type: 'student' }); const students = await getUsers({ type: 'student' }, 10, { averageLevel: -1 });
const usersCount = { const usersCount = {
student: await countUsers({ type: "student" }), student: await countUsers({ type: "student" }),
teacher: await countUsers({ type: "teacher" }), teacher: await countUsers({ type: "teacher" }),
@@ -66,20 +66,18 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } }); const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } });
const groupsCount = await countGroups(); const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, 'id')); return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, groupsCount }) };
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) };
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ export default function Dashboard({
user, user,
students, students = [],
latestStudents, latestStudents,
latestTeachers, latestTeachers,
usersCount, usersCount,
entities, entities,
assignmentsCount, assignmentsCount,
stats, stats = [],
groupsCount groupsCount
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
@@ -139,7 +137,6 @@ export default function Dashboard({
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPersonFillGear} <IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
@@ -171,7 +168,7 @@ export default function Dashboard({
title="Latest Teachers" title="Latest Teachers"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList

View File

@@ -6,7 +6,7 @@ import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { Group, Stat, Type, User } from "@/interfaces/user"; import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
@@ -17,7 +17,7 @@ import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { countAllowedUsers, filterAllowedUsers } from "@/utils/users.be"; import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
@@ -44,7 +44,9 @@ import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
users: User[]; students: StudentUser[]
latestStudents: User[]
latestTeachers: User[]
userCounts: { [key in Type]: number } userCounts: { [key in Type]: number }
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignmentsCount: number; assignmentsCount: number;
@@ -56,29 +58,34 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/")
return redirect("/")
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const users = await filterAllowedUsers(user, entities)
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students")
const allowedTeacherEntities = findAllowedEntities(user, entities, "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 userCounts = await countAllowedUsers(user, entities) const userCounts = await countAllowedUsers(user, entities)
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } }); const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
const groupsCount = await countGroupsByEntities(mapBy(entities, "id")); const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
const stats = await getStatsByUsers(users.map((u) => u.id)); return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) };
return { props: serialize({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }) };
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }: Props) { export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const totalCount = useMemo(() => const totalCount = useMemo(() =>
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + curr.licenses, 0), [entities])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities])
const router = useRouter(); const router = useRouter();
@@ -133,7 +140,6 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
value={`${entities.length} - ${totalCount}/${totalLicenses}`} value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple" color="purple"
/> />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
@@ -168,15 +174,15 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={latestStudents}
title="Latest Students" title="Latest Students"
/> />
<UserDisplayList <UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={latestTeachers}
title="Latest Teachers" title="Latest Teachers"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList

View File

@@ -96,7 +96,6 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
value={groups.length} value={groups.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}

View File

@@ -2,6 +2,8 @@
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input";
import Checkbox from "@/components/Low/Checkbox";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
@@ -27,7 +29,11 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker";
import { CURRENCIES } from "@/resources/paypal";
import { import {
BsCheck,
BsChevronLeft, BsChevronLeft,
BsClockFill, BsClockFill,
BsEnvelopeFill, BsEnvelopeFill,
@@ -43,6 +49,20 @@ import {
} from "react-icons/bs"; } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
const expirationDateColor = (date: Date) => {
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";
};
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
value: currency,
label,
}));
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
const user = req.session.user as User; const user = req.session.user as User;
@@ -88,6 +108,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate)
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price)
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency)
const router = useRouter(); const router = useRouter();
@@ -99,6 +122,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity") const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity")
const canAssignRole = useEntityPermission(user, entity, "assign_to_role") const canAssignRole = useEntityPermission(user, entity, "assign_to_role")
const canPay = useEntityPermission(user, entity, 'pay_entity')
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
@@ -166,6 +190,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const updateExpiryDate = () => {
if (!isAdmin(user)) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, { expiryDate })
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const updatePayment = () => {
if (!isAdmin(user)) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } })
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const editLicenses = () => { const editLicenses = () => {
if (!isAdmin(user)) return; if (!isAdmin(user)) return;
@@ -289,7 +347,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Layout user={user}> <Layout user={user}>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-end justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/entities" href="/entities"
@@ -298,6 +356,20 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
</Link> </Link>
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2> <h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2>
</div> </div>
{!isAdmin(user) && canPay && (
<Link
href="/payment"
className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!entity.expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(entity.expiryDate),
"bg-white border-mti-gray-platinum",
)}>
{!entity.expiryDate && "Unlimited"}
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")}
</Link>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -332,6 +404,91 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
</button> </button>
</div> </div>
</div> </div>
{isAdmin(user) && (
<>
<Divider />
<div className="w-full flex justify-between items-center">
<div className="flex items-center gap-4 w-full">
{!!expiryDate && (
<ReactDatePicker
className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"transition duration-300 ease-in-out",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
selected={expiryDate ? moment(expiryDate).toDate() : null}
onChange={(date) => setExpiryDate(date)}
/>
)}
{!expiryDate && (
<div
className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum",
)}
>
Unlimited
</div>
)}
<Checkbox
isChecked={!!expiryDate}
onChange={(checked: boolean) => setExpiryDate(checked ? entity.expiryDate || new Date() : null)}
>
Enable expiry date
</Checkbox>
</div>
<button
onClick={updateExpiryDate}
disabled={expiryDate === entity.expiryDate}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsCheck />
<span className="text-xs">Apply Change</span>
</button>
</div>
<Divider />
<div className="w-full flex items-center justify-between gap-8">
<div className="w-full max-w-xl flex items-center gap-4">
<Input
name="paymentValue"
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)}
type="number"
defaultValue={entity.payment?.price || 0}
thin
/>
<Select
className={clsx(
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
)}
options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)}
/>
</div>
<button
onClick={updatePayment}
disabled={!paymentPrice || paymentPrice <= 0 || !paymentCurrency}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsCheck />
<span className="text-xs">Apply Change</span>
</button>
</div>
</>
)}
<Divider /> <Divider />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({users.length})</span> <span className="font-semibold text-xl">Members ({users.length})</span>

View File

@@ -47,6 +47,7 @@ const USER_MANAGEMENT: PermissionLayout[] = [
{ label: "Create Users in Batch", key: "create_user_batch" }, { label: "Create Users in Batch", key: "create_user_batch" },
{ label: "Create a Single Code", key: "create_code" }, { label: "Create a Single Code", key: "create_code" },
{ label: "Create Codes in Batch", key: "create_code_batch" }, { label: "Create Codes in Batch", key: "create_code_batch" },
{ label: "Download User List", key: "download_user_list" },
{ label: "View Code List", key: "view_code_list" }, { label: "View Code List", key: "view_code_list" },
{ label: "Delete Code", key: "delete_code" }, { label: "Delete Code", key: "delete_code" },
] ]
@@ -75,8 +76,11 @@ const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
{ label: "Create Classrooms", key: "create_classroom" }, { label: "Create Classrooms", key: "create_classroom" },
{ label: "Rename Classrooms", key: "rename_classrooms" }, { label: "Rename Classrooms", key: "rename_classrooms" },
{ label: "Add to Classroom", key: "add_to_classroom" }, { label: "Add to Classroom", key: "add_to_classroom" },
{ label: "Upload to Classroom", key: "upload_classroom" },
{ label: "Remove from Classroom", key: "remove_from_classroom" }, { label: "Remove from Classroom", key: "remove_from_classroom" },
{ label: "Delete Classroom", key: "delete_classroom" }, { label: "Delete Classroom", key: "delete_classroom" },
{ label: "View Student Record", key: "view_student_record" },
{ label: "Download Student Report", key: "download_student_record" },
] ]
const ENTITY_MANAGEMENT: PermissionLayout[] = [ const ENTITY_MANAGEMENT: PermissionLayout[] = [
@@ -94,7 +98,9 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [
{ label: "Delete Entity Role", key: "delete_entity_role" }, { label: "Delete Entity Role", key: "delete_entity_role" },
{ label: "Download Statistics Report", key: "download_statistics_report" }, { label: "Download Statistics Report", key: "download_statistics_report" },
{ label: "Edit Grading System", key: "edit_grading_system" }, { label: "Edit Grading System", key: "edit_grading_system" },
{ label: "View Student Performance", key: "view_student_performance" } { label: "View Student Performance", key: "view_student_performance" },
{ label: "Pay for Entity", key: "pay_entity" },
{ label: "View Payment Record", key: "view_payment_record" }
] ]
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [ const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [

View File

@@ -32,7 +32,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, query })
const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string } const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string }
const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined
if (assignmentID) { if (!!assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID)
if (!assignment) return redirect(destinationURL || "/exam") if (!assignment) return redirect(destinationURL || "/exam")

View File

@@ -30,9 +30,12 @@ import { toFixedNumber } from "@/utils/number";
import { CSVLink } from "react-csv"; import { CSVLink } from "react-csv";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
@@ -42,8 +45,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return redirect("/") return redirect("/")
} }
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const allowedEntities = findAllowedEntities(user, entities, "view_payment_record")
return { return {
props: { user }, props: serialize({ user, entities: allowedEntities }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -273,7 +281,13 @@ interface PaypalPaymentWithUserData extends PaypalPayment {
} }
const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]]; const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]];
export default function PaymentRecord() {
interface Props {
user: User
entities: EntityWithRoles[]
}
export default function PaymentRecord({ user, entities }: Props) {
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>(); const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
const [selectedAgentUser, setSelectedAgentUser] = useState<User>(); const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
const [isCreatingPayment, setIsCreatingPayment] = useState(false); const [isCreatingPayment, setIsCreatingPayment] = useState(false);
@@ -281,9 +295,9 @@ export default function PaymentRecord() {
const [displayPayments, setDisplayPayments] = useState<Payment[]>([]); const [displayPayments, setDisplayPayments] = useState<Payment[]>([]);
const [corporate, setCorporate] = useState<User>(); const [corporate, setCorporate] = useState<User>();
const [entity, setEntity] = useState<Entity>();
const [agent, setAgent] = useState<User>(); const [agent, setAgent] = useState<User>();
const { user } = useUser({ redirectTo: "/login" });
const { users, reload: reloadUsers } = useUsers(); const { users, reload: reloadUsers } = useUsers();
const { payments: originalPayments, reload: reloadPayment } = usePayments(); const { payments: originalPayments, reload: reloadPayment } = usePayments();
const { payments: paypalPayments, reload: reloadPaypalPayment } = usePaypalPayments(); const { payments: paypalPayments, reload: reloadPaypalPayment } = usePaypalPayments();
@@ -341,17 +355,17 @@ export default function PaymentRecord() {
useEffect(() => { useEffect(() => {
setFilters((prev) => [ setFilters((prev) => [
...prev.filter((x) => x.id !== "corporate-filter"), ...prev.filter((x) => x.id !== "entity-filter"),
...(!corporate ...(!entity
? [] ? []
: [ : [
{ {
id: "corporate-filter", id: "entity-filter",
filter: (p: Payment) => p.corporate === corporate.id, filter: (p: Payment) => p.entity === entity.id,
}, },
]), ]),
]); ]);
}, [corporate]); }, [entity]);
useEffect(() => { useEffect(() => {
setFilters((prev) => [ setFilters((prev) => [
@@ -675,7 +689,7 @@ export default function PaymentRecord() {
<Checkbox <Checkbox
isChecked={value} isChecked={value}
onChange={(e) => { onChange={(e) => {
if (user?.type === agent || user?.type === "corporate" || value) return null; if (user?.type === "agent" || user?.type === "corporate" || value) return null;
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer) if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
return alert("All files need to be uploaded to consider it paid!"); return alert("All files need to be uploaded to consider it paid!");
if (!confirm(`Are you sure you want to consider this payment paid?`)) return null; if (!confirm(`Are you sure you want to consider this payment paid?`)) return null;

View File

@@ -7,29 +7,39 @@ import PaymentDue from "./(status)/PaymentDue";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntities } from "@/utils/entities.be"; import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import client from "@/lib/mongodb";
import { Discount, Package } from "@/interfaces/paypal";
const db = client.db(process.env.MONGODB_DB);
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") if (!user) return redirect("/login")
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntities(isAdmin(user) ? undefined : entityIDs) const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const domain = user.email.split("@").pop()
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray()
const packages = await db.collection<Package>("packages").find().toArray()
return { return {
props: serialize({ user, entities }), props: serialize({ user, entities, discounts, packages }),
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User, user: User,
entities: EntityWithRoles[] entities: EntityWithRoles[]
discounts: Discount[]
packages: Package[]
} }
export default function Home({ user, entities }: Props) { export default function Home(props: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
@@ -43,7 +53,8 @@ export default function Home({ user, entities }: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<PaymentDue entities={entities} user={user} reload={router.reload} />
<PaymentDue {...props} reload={router.reload} />
</> </>
); );
} }

View File

@@ -22,7 +22,7 @@ import { Assignment } from "@/interfaces/results";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import useGradingSystem from "@/hooks/useGrading"; import useGradingSystem from "@/hooks/useGrading";
import { mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
@@ -31,6 +31,7 @@ import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
@@ -74,6 +75,7 @@ export default function History({ user, users, assignments, entities, gradingSys
const [filter, setFilter] = useState<Filter>(); const [filter, setFilter] = useState<Filter>();
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id); const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record')
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
@@ -155,6 +157,9 @@ export default function History({ user, users, assignments, entities, gradingSys
const customContent = (timestamp: string) => { const customContent = (timestamp: string) => {
const dateStats = groupedStats[timestamp]; const dateStats = groupedStats[timestamp];
const statUser = findBy(users, 'id', dateStats[0]?.user)
const canDownload = mapBy(statUser?.entities, 'id').some(e => mapBy(allowedDownloadEntities, 'id').includes(e))
return ( return (
<StatsGridItem <StatsGridItem
@@ -169,7 +174,7 @@ export default function History({ user, users, assignments, entities, gradingSys
selectedTrainingExams={selectedTrainingExams} selectedTrainingExams={selectedTrainingExams}
setSelectedTrainingExams={setSelectedTrainingExams} setSelectedTrainingExams={setSelectedTrainingExams}
maxTrainingExams={MAX_TRAINING_EXAMS} maxTrainingExams={MAX_TRAINING_EXAMS}
renderPdfIcon={renderPdfIcon} renderPdfIcon={canDownload ? renderPdfIcon : undefined}
/> />
); );
}; };

View File

@@ -59,7 +59,13 @@ export type RolePermission =
"view_statistics" | "view_statistics" |
"download_statistics_report" | "download_statistics_report" |
"edit_grading_system" | "edit_grading_system" |
"view_student_performance" "view_student_performance" |
"upload_classroom" |
"download_user_list" |
"view_student_record" |
"download_student_record" |
"pay_entity" |
"view_payment_record"
export const DEFAULT_PERMISSIONS: RolePermission[] = [ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students", "view_students",
@@ -132,5 +138,11 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
"view_statistics", "view_statistics",
"download_statistics_report", "download_statistics_report",
"edit_grading_system", "edit_grading_system",
"view_student_performance" "view_student_performance",
"upload_classroom",
"download_user_list",
"view_student_record",
"download_student_record",
"pay_entity",
"view_payment_record"
] ]

View File

@@ -6,181 +6,181 @@ import { levelPart, listeningSection, readingPart, speakingTask, writingTask } f
export const defaultSettings = (module: Module) => { export const defaultSettings = (module: Module) => {
const baseSettings = { const baseSettings = {
category: '', category: '',
introOption: { label: 'None', value: 'None' }, introOption: { label: 'None', value: 'None' },
customIntro: '', customIntro: '',
currentIntro: '', currentIntro: '',
topic: '', topic: '',
isCategoryDropdownOpen: false, isCategoryDropdownOpen: false,
isIntroDropdownOpen: false, isIntroDropdownOpen: false,
isExerciseDropdownOpen: false, isExerciseDropdownOpen: false,
isTypeDropdownOpen: false, isTypeDropdownOpen: false,
} }
switch (module) { switch (module) {
case 'writing': case 'writing':
return { return {
...baseSettings, ...baseSettings,
writingTopic: '', writingTopic: '',
isWritingTopicOpen: false, isWritingTopicOpen: false,
isImageUploadOpen: false, isImageUploadOpen: false,
} }
case 'reading': case 'reading':
return { return {
...baseSettings, ...baseSettings,
isPassageOpen: false, isPassageOpen: false,
readingTopic: '', readingTopic: '',
isReadingTopicOpean: false, isReadingTopicOpean: false,
} }
case 'listening': case 'listening':
return { return {
...baseSettings, ...baseSettings,
isAudioContextOpen: false, isAudioContextOpen: false,
isAudioGenerationOpen: false, isAudioGenerationOpen: false,
listeningTopic: '', listeningTopic: '',
isListeningTopicOpen: false, isListeningTopicOpen: false,
} }
case 'speaking': case 'speaking':
return { return {
...baseSettings, ...baseSettings,
speakingTopic: '', speakingTopic: '',
speakingSecondTopic: '', speakingSecondTopic: '',
isSpeakingTopicOpen: false, isSpeakingTopicOpen: false,
isGenerateVideoOpen: false, isGenerateVideoOpen: false,
} }
case 'level': case 'level':
return { return {
...baseSettings, ...baseSettings,
isReadingDropdownOpen: false, isReadingDropdownOpen: false,
isWritingDropdownOpen: false, isWritingDropdownOpen: false,
isSpeakingDropdownOpen: false, isSpeakingDropdownOpen: false,
isListeningDropdownOpen: false, isListeningDropdownOpen: false,
isWritingTopicOpen: false, isWritingTopicOpen: false,
isImageUploadOpen: false, isImageUploadOpen: false,
writingTopic: '', writingTopic: '',
isPassageOpen: false, isPassageOpen: false,
readingTopic: '', readingTopic: '',
isReadingTopicOpean: false, isReadingTopicOpean: false,
isAudioContextOpen: false, isAudioContextOpen: false,
isAudioGenerationOpen: false, isAudioGenerationOpen: false,
listeningTopic: '', listeningTopic: '',
isListeningTopicOpen: false, isListeningTopicOpen: false,
speakingTopic: '', speakingTopic: '',
speakingSecondTopic: '', speakingSecondTopic: '',
isSpeakingTopicOpen: false, isSpeakingTopicOpen: false,
isGenerateVideoOpen: false, isGenerateVideoOpen: false,
} }
default: default:
return baseSettings; return baseSettings;
} }
} }
export const sectionLabels = (module: Module, levelParts?: number) => { export const sectionLabels = (module: Module, levelParts?: number) => {
switch (module) { switch (module) {
case 'reading': case 'reading':
return Array.from({ length: 3 }, (_, index) => ({ return Array.from({ length: 3 }, (_, index) => ({
id: index + 1, id: index + 1,
label: `Passage ${index + 1}` label: `Passage ${index + 1}`
})); }));
case 'writing': case 'writing':
return [{ id: 1, label: "Task 1" }, { id: 2, label: "Task 2" }]; return [{ id: 1, label: "Task 1" }, { id: 2, label: "Task 2" }];
case 'speaking': case 'speaking':
return [{ id: 1, label: "Speaking 1" }, { id: 2, label: "Speaking 2" }, { id: 3, label: "Interactive Speaking" }]; return [{ id: 1, label: "Speaking 1" }, { id: 2, label: "Speaking 2" }, { id: 3, label: "Interactive Speaking" }];
case 'listening': case 'listening':
return Array.from({ length: 4 }, (_, index) => ({ return Array.from({ length: 4 }, (_, index) => ({
id: index + 1, id: index + 1,
label: `Section ${index + 1}` label: `Section ${index + 1}`
})); }));
case 'level': case 'level':
return levelParts !== undefined ? return levelParts !== undefined ?
Array.from({ length: levelParts }, (_, index) => ({ Array.from({ length: levelParts }, (_, index) => ({
id: index + 1, id: index + 1,
label: `Part ${index + 1}` label: `Part ${index + 1}`
})) }))
: :
[{ id: 1, label: "Part 1" }]; [{ id: 1, label: "Part 1" }];
} }
} }
const defaultExamLabel = (module: Module) => { const defaultExamLabel = (module: Module) => {
switch (module) { switch (module) {
case 'reading': case 'reading':
return "Reading Exam"; return "Reading Exam";
case 'writing': case 'writing':
return "Writing Exam"; return "Writing Exam";
case 'speaking': case 'speaking':
return "Speaking Exam"; return "Speaking Exam";
case 'listening': case 'listening':
return "Listening Exam"; return "Listening Exam";
case 'level': case 'level':
return "Placement Test"; return "Placement Test";
} }
} }
const defaultSection = (module: Module, sectionId: number) => { const defaultSection = (module: Module, sectionId: number) => {
switch (module) { switch (module) {
case 'reading': case 'reading':
return readingPart(sectionId); return readingPart(sectionId);
case 'writing': case 'writing':
return writingTask(sectionId); return writingTask(sectionId);
case 'listening': case 'listening':
return listeningSection(sectionId) return listeningSection(sectionId)
case 'speaking': case 'speaking':
return speakingTask(sectionId) return speakingTask(sectionId)
case 'level': case 'level':
return levelPart(sectionId) return levelPart(sectionId)
} }
} }
export const defaultSectionSettings = (module: Module, sectionId: number, part?: ExamPart) => { export const defaultSectionSettings = (module: Module, sectionId: number, part?: ExamPart) => {
return { return {
sectionId: sectionId, sectionId: sectionId,
settings: defaultSettings(module), settings: defaultSettings(module),
state: part !== undefined ? part : defaultSection(module, sectionId), state: part !== undefined ? part : defaultSection(module, sectionId),
generating: undefined, generating: undefined,
genResult: undefined, genResult: undefined,
focusedExercise: undefined, focusedExercise: undefined,
expandedSubSections: [], expandedSubSections: [],
levelGenerating: [], levelGenerating: [],
levelGenResults: [], levelGenResults: [],
scriptLoading: false, scriptLoading: false,
} }
} }
const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => { const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => {
const state: ModuleState = { const state: ModuleState = {
examLabel: defaultExamLabel(module), examLabel: defaultExamLabel(module),
minTimer, minTimer,
difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!, difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!,
isPrivate: false, isPrivate: true,
sectionLabels: sectionLabels(module), sectionLabels: sectionLabels(module),
expandedSections: [1], expandedSections: [1],
focusedSection: 1, focusedSection: 1,
sections: [defaultSectionSettings(module, 1)], sections: [defaultSectionSettings(module, 1)],
importModule: true, importModule: true,
importing: false, importing: false,
edit: [], edit: [],
instructionsState: { instructionsState: {
isInstructionsOpen: false, isInstructionsOpen: false,
chosenOption: { value: "Automatic", label: "Automatic" }, chosenOption: { value: "Automatic", label: "Automatic" },
currentInstructions: "", currentInstructions: "",
presetInstructions: "", presetInstructions: "",
customInstructions: "", customInstructions: "",
currentInstructionsURL: "", currentInstructionsURL: "",
presetInstructionsURL: "", presetInstructionsURL: "",
customInstructionsURL: "", customInstructionsURL: "",
}, },
}; };
if (["reading", "writing"].includes(module)) { if (["reading", "writing"].includes(module)) {
state["type"] = "general"; state["type"] = "general";
} }
return state; return state;
} }
export default defaultModuleSettings; export default defaultModuleSettings;

View File

@@ -1,8 +1,8 @@
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; import { Entity, EntityWithRoles, Role } from "@/interfaces/entity";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { ADMIN_PERMISSIONS, DEFAULT_PERMISSIONS, RolePermission } from "@/resources/entityPermissions"; import { ADMIN_PERMISSIONS, DEFAULT_PERMISSIONS, RolePermission } from "@/resources/entityPermissions";
import { v4 } from "uuid"; import { v4 } from "uuid";
import {getRolesByEntities, getRolesByEntity} from "./roles.be"; import { getRolesByEntities, getRolesByEntity } from "./roles.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -11,28 +11,28 @@ export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles |
if (!entity) return undefined; if (!entity) return undefined;
const roles = await getRolesByEntity(id); const roles = await getRolesByEntity(id);
return {...entity, roles}; return { ...entity, roles };
}; };
export const getEntity = async (id: string) => { export const getEntity = async (id: string) => {
return (await db.collection("entities").findOne<Entity>({id})) ?? undefined; return (await db.collection("entities").findOne<Entity>({ id })) ?? undefined;
}; };
export const getEntitiesWithRoles = async (ids?: string[]): Promise<EntityWithRoles[]> => { export const getEntitiesWithRoles = async (ids?: string[]): Promise<EntityWithRoles[]> => {
const entities = await db const entities = await db
.collection("entities") .collection("entities")
.find<Entity>(ids ? {id: {$in: ids}} : {}) .find<Entity>(ids ? { id: { $in: ids } } : {})
.toArray(); .toArray();
const roles = await getRolesByEntities(entities.map((x) => x.id)); const roles = await getRolesByEntities(entities.map((x) => x.id));
return entities.map((x) => ({...x, roles: roles.filter((y) => y.entityID === x.id) || []})); return entities.map((x) => ({ ...x, roles: roles.filter((y) => y.entityID === x.id) || [] }));
}; };
export const getEntities = async (ids?: string[]) => { export const getEntities = async (ids?: string[]) => {
return await db return await db
.collection("entities") .collection("entities")
.find<Entity>(ids ? {id: {$in: ids}} : {}) .find<Entity>(ids ? { id: { $in: ids } } : {})
.toArray(); .toArray();
}; };
@@ -57,41 +57,52 @@ export const createEntity = async (entity: Entity) => {
await db.collection("roles").insertOne(defaultRole) await db.collection("roles").insertOne(defaultRole)
await db.collection("roles").insertOne(adminRole) await db.collection("roles").insertOne(adminRole)
return {default: defaultRole, admin: adminRole} return { default: defaultRole, admin: adminRole }
} }
export const addUserToEntity = async (user: string, entity: string, role: string) => export const addUserToEntity = async (user: string, entity: string, role: string) =>
await db.collection("users").updateOne( await db.collection("users").updateOne(
{id: user}, { id: user },
{ {
// @ts-expect-error // @ts-expect-error
$push: { $push: {
entities: {id: entity, role}, entities: { id: entity, role },
}, },
}, },
); );
export const addUsersToEntity = async (users: string[], entity: string, role: string) => export const addUsersToEntity = async (users: string[], entity: string, role: string) =>
await db.collection("users").updateMany( await db.collection("users").updateMany(
{id: {$in: users}}, { id: { $in: users } },
{ {
// @ts-expect-error // @ts-expect-error
$push: { $push: {
entities: {id: entity, role}, entities: { id: entity, role },
},
},
);
export const removeUsersFromEntity = async (users: string[], entity: string) =>
await db.collection("users").updateMany(
{ id: { $in: users } },
{
// @ts-expect-error
$pull: {
entities: { id: entity },
}, },
}, },
); );
export const deleteEntity = async (entity: Entity) => { export const deleteEntity = async (entity: Entity) => {
await db.collection("entities").deleteOne({id: entity.id}) await db.collection("entities").deleteOne({ id: entity.id })
await db.collection("roles").deleteMany({entityID: entity.id}) await db.collection("roles").deleteMany({ entityID: entity.id })
await db.collection("users").updateMany( await db.collection("users").updateMany(
{"entities.id": entity.id}, { "entities.id": entity.id },
{ {
// @ts-expect-error // @ts-expect-error
$pull: { $pull: {
entities: {id: entity.id}, entities: { id: entity.id },
}, },
}, },
); );

View File

@@ -9,6 +9,7 @@ import { Db, ObjectId } from "mongodb";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { MODULE_ARRAY } from "./moduleUtils"; import { MODULE_ARRAY } from "./moduleUtils";
import { mapBy } from "."; import { mapBy } from ".";
import { getUser } from "./users.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -76,7 +77,7 @@ export const getExams = async (
})) as Exam[], })) as Exam[],
).filter((x) => !x.private); ).filter((x) => !x.private);
let exams: Exam[] = await filterByOwners(shuffledPublicExams, userId); let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId);
exams = filterByVariant(exams, variant); exams = filterByVariant(exams, variant);
exams = filterByInstructorGender(exams, instructorGender); exams = filterByInstructorGender(exams, instructorGender);
exams = await filterByDifficulty(db, exams, module, userId); exams = await filterByDifficulty(db, exams, module, userId);
@@ -109,16 +110,17 @@ const filterByVariant = (exams: Exam[], variant?: Variant) => {
return filtered.length > 0 ? filtered : exams; return filtered.length > 0 ? filtered : exams;
}; };
const filterByOwners = async (exams: Exam[], userID?: string) => { const filterByEntities = async (exams: Exam[], userID?: string) => {
if (!userID) return exams.filter((x) => !x.owners || x.owners.length === 0); if (!userID) return exams.filter((x) => !x.entities || x.entities.length === 0);
const user = await getUser(userID)
return await Promise.all( return await Promise.all(
exams.filter(async (x) => { exams.filter(async (x) => {
if (!x.owners) return true; if (!x.entities) return true;
if (x.owners.length === 0) return true; if (x.entities.length === 0) return true;
if (x.owners.includes(userID)) return true;
const corporate = await getUserCorporate(userID); return mapBy(user?.entities || [], 'id').some(e => x.entities!.includes(e))
return !corporate ? false : x.owners.includes(corporate.id);
}), }),
); );
}; };

View File

@@ -3,6 +3,7 @@ import { WithEntity } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User } from "@/interfaces/user"; import { CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User } from "@/interfaces/user";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { uniq } from "lodash";
import moment from "moment"; import moment from "moment";
import { getLinkedUsers, getUser } from "./users.be"; import { getLinkedUsers, getUser } from "./users.be";
import { getSpecificUsers } from "./users.be"; import { getSpecificUsers } from "./users.be";
@@ -116,7 +117,7 @@ export const getUsersGroups = async (ids: string[]) => {
export const convertToUsers = (group: Group, users: User[]): GroupWithUsers => export const convertToUsers = (group: Group, users: User[]): GroupWithUsers =>
Object.assign(group, { Object.assign(group, {
admin: users.find((u) => u.id === group.admin), admin: users.find((u) => u.id === group.admin),
participants: group.participants.map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[], participants: uniq(group.participants).map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[],
}); });
export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise<string[]> => { export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise<string[]> => {

View File

@@ -1,4 +1,4 @@
import {Role} from "@/interfaces/entity"; import { Role } from "@/interfaces/entity";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -6,16 +6,18 @@ const db = client.db(process.env.MONGODB_DB);
export const getRolesByEntities = async (entityIDs: string[]) => export const getRolesByEntities = async (entityIDs: string[]) =>
await db await db
.collection("roles") .collection("roles")
.find<Role>({entityID: {$in: entityIDs}}) .find<Role>({ entityID: { $in: entityIDs } })
.toArray(); .toArray();
export const getRolesByEntity = async (entityID: string) => await db.collection("roles").find<Role>({entityID}).toArray(); export const getRolesByEntity = async (entityID: string) => await db.collection("roles").find<Role>({ entityID }).toArray();
export const getRoles = async (ids?: string[]) => await db.collection("roles").find<Role>(!ids ? {} : {id: {$in: ids}}).toArray(); export const getRoles = async (ids?: string[]) => await db.collection("roles").find<Role>(!ids ? {} : { id: { $in: ids } }).toArray();
export const getRole = async (id: string) => (await db.collection("roles").findOne<Role>({id})) ?? undefined; export const getRole = async (id: string) => (await db.collection("roles").findOne<Role>({ id })) ?? undefined;
export const getDefaultRole = async (entityID: string) => await db.collection("roles").findOne<Role>({ isDefault: true, entityID })
export const createRole = async (role: Role) => await db.collection("roles").insertOne(role) export const createRole = async (role: Role) => await db.collection("roles").insertOne(role)
export const deleteRole = async (id: string) => await db.collection("roles").deleteOne({id}) export const deleteRole = async (id: string) => await db.collection("roles").deleteOne({ id })
export const transferRole = async (previousRole: string, newRole: string) => export const transferRole = async (previousRole: string, newRole: string) =>
await db.collection("users") await db.collection("users")

View File

@@ -171,7 +171,7 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
const student = await countEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" }) const student = await countEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
const teacher = await countEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" }) const teacher = await countEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" })
const corporate = await countEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" }) const corporate = await countEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" })
const masterCorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" }) const mastercorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" })
return { student, teacher, corporate, masterCorporate } return { student, teacher, corporate, mastercorporate }
} }