Merged in ENCOA-314 (pull request #137)

ENCOA-314

Approved-by: Tiago Ribeiro
This commit is contained in:
Francisco Lima
2025-01-20 17:15:33 +00:00
committed by Tiago Ribeiro
17 changed files with 1856 additions and 1043 deletions

View File

@@ -0,0 +1,117 @@
import clsx from "clsx";
import { useEffect, useState } from "react";
import { GroupBase, StylesConfig } from "react-select";
import ReactSelect from "react-select";
import Option from "@/interfaces/option";
interface Props {
defaultValue?: Option | Option[];
options: Option[];
value?: Option | Option[] | null;
isLoading?: boolean;
loadOptions: (inputValue: string) => void;
onMenuScrollToBottom: (event: WheelEvent | TouchEvent) => void;
disabled?: boolean;
placeholder?: string;
isClearable?: boolean;
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
className?: string;
label?: string;
flat?: boolean;
}
interface MultiProps {
isMulti: true;
onChange: (value: Option[] | null) => void;
}
interface SingleProps {
isMulti?: false;
onChange: (value: Option | null) => void;
}
export default function AsyncSelect({
value,
isMulti,
defaultValue,
options,
loadOptions,
onMenuScrollToBottom,
placeholder,
disabled,
onChange,
styles,
isClearable,
isLoading,
label,
className,
flat,
}: Props & (MultiProps | SingleProps)) {
const [target, setTarget] = useState<HTMLElement>();
useEffect(() => {
if (document) setTarget(document.body);
}, []);
return (
<div className="w-full flex flex-col gap-3">
{label && (
<label className="font-normal text-base text-mti-gray-dim">
{label}
</label>
)}
<ReactSelect
isMulti={isMulti}
className={
styles
? undefined
: clsx(
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full border bg-white text-sm font-normal focus:outline-none",
disabled &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
flat ? "rounded-md" : "px-4 py-4 rounded-full",
className
)
}
isLoading={isLoading}
filterOption={null}
loadingMessage={() => "Loading..."}
onInputChange={(inputValue) => {
loadOptions(inputValue);
}}
options={options}
value={value}
onChange={onChange as any}
placeholder={placeholder}
menuPortalTarget={target}
defaultValue={defaultValue}
onMenuScrollToBottom={onMenuScrollToBottom}
styles={
styles || {
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}
}
isDisabled={disabled}
isClearable={isClearable}
/>
</div>
);
}

View File

@@ -9,21 +9,22 @@ import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import useUsersSelect from "../../hooks/useUsersSelect";
import AsyncSelect from "../Low/AsyncSelect";
type TimeFilter = "months" | "weeks" | "days";
type Filter = TimeFilter | "assignments" | undefined;
interface Props {
user: User;
entities: EntityWithRoles[]
users: User[]
entities: EntityWithRoles[];
isAdmin?: boolean;
filterState: {
filter: Filter,
setFilter: React.Dispatch<React.SetStateAction<Filter>>
},
filter: Filter;
setFilter: React.Dispatch<React.SetStateAction<Filter>>;
};
assignments?: boolean;
children?: ReactNode
children?: ReactNode;
}
const defaultSelectableCorporate = {
@@ -34,25 +35,42 @@ const defaultSelectableCorporate = {
const RecordFilter: React.FC<Props> = ({
user,
entities,
users,
filterState,
assignments = true,
children
isAdmin = false,
children,
}) => {
const { filter, setFilter } = filterState;
const [entity, setEntity] = useState<string>()
const [entity, setEntity] = useState<string>();
const [, setStatsUserId] = useRecordStore((state) => [
state.selectedUser,
state.setSelectedUser
state.setSelectedUser,
]);
const allowedViewEntities = useAllowedEntities(user, entities, 'view_student_record')
const entitiesToSearch = useMemo(() => {
if(entity) return entity
if (isAdmin) return undefined;
return mapBy(entities, "id");
}, [entities, entity, isAdmin]);
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity])
const { users, isLoading, onScrollLoadMoreOptions, loadOptions } =
useUsersSelect({
size: 50,
orderBy: "name",
direction: "asc",
entities: entitiesToSearch,
});
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id])
const allowedViewEntities = useAllowedEntities(
user,
entities,
"view_student_record"
);
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
setFilter((prev) => (prev === value ? undefined : value));
@@ -61,39 +79,59 @@ const RecordFilter: React.FC<Props> = ({
return (
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4 flex gap-2">
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
{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>
<label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select
options={allowedViewEntities.map((e) => ({ value: e.id, label: e.label }))}
options={allowedViewEntities.map((e) => ({
value: e.id,
label: e.label,
}))}
onChange={(value) => setEntity(value?.value || undefined)}
isClearable
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
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>
<label className="font-normal text-base text-mti-gray-dim">
User
</label>
<Select
options={entityUsers.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
<AsyncSelect
isLoading={isLoading}
loadOptions={loadOptions}
onMenuScrollToBottom={onScrollLoadMoreOptions}
options={users}
defaultValue={{
value: user.id,
label: `${user.name} - ${user.email}`,
}}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
@@ -101,23 +139,32 @@ const RecordFilter: React.FC<Props> = ({
</div>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && !children && (
{(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>
<label className="font-normal text-base text-mti-gray-dim">
User
</label>
<Select
options={users
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
<AsyncSelect
isLoading={isLoading}
loadOptions={loadOptions}
onMenuScrollToBottom={onScrollLoadMoreOptions}
options={users}
defaultValue={{
value: user.id,
label: `${user.name} - ${user.email}`,
}}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
@@ -132,9 +179,10 @@ const RecordFilter: React.FC<Props> = ({
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 === "assignments" && "!bg-mti-purple-light !text-white",
filter === "assignments" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("assignments")}>
onClick={() => toggleFilter("assignments")}
>
Assignments
</button>
)}
@@ -142,32 +190,35 @@ const RecordFilter: React.FC<Props> = ({
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 === "months" && "!bg-mti-purple-light !text-white",
filter === "months" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("months")}>
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",
filter === "weeks" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("weeks")}>
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",
filter === "days" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("days")}>
onClick={() => toggleFilter("days")}
>
Last day
</button>
</div>
</div>
);
}
};
export default RecordFilter;

View File

@@ -3,16 +3,24 @@ import {useMemo, useState} from "react";
import { Module } from "@/interfaces";
import clsx from "clsx";
import { Stat, User } from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {
BsArrowRepeat,
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import Button from "@/components/Low/Button";
import {calculateAverageLevel} from "@/utils/score";
import { sortByModuleName } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import ProfileSummary from "@/components/ProfileSummary";
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
import { Variant } from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/exam";
@@ -21,7 +29,11 @@ import moment from "moment";
interface Props {
user: User;
page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
onStart: (
modules: Module[],
avoidRepeated: boolean,
variant: Variant
) => void;
}
export default function Selection({ user, page, onStart }: Props) {
@@ -36,15 +48,21 @@ export default function Selection({user, page, onStart}: Props) {
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module]
);
};
const isCompleteExam = useMemo(() =>
["reading", "listening", "writing", "speaking"].every(m => selectedModules.includes(m as Module)), [selectedModules]
)
const isCompleteExam = useMemo(
() =>
["reading", "listening", "writing", "speaking"].every((m) =>
selectedModules.includes(m as Module)
),
[selectedModules]
);
const loadSession = async (session: Session) => {
dispatch({type: "SET_SESSION", payload: { session }})
dispatch({ type: "SET_SESSION", payload: { session } });
};
return (
@@ -55,31 +73,41 @@ export default function Selection({user, page, onStart}: Props) {
user={user}
items={[
{
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
icon: (
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
),
label: "Reading",
value: totalExamsByModule(stats, "reading"),
tooltip: "The amount of reading exams performed.",
},
{
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
icon: (
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
),
label: "Listening",
value: totalExamsByModule(stats, "listening"),
tooltip: "The amount of listening exams performed.",
},
{
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
icon: (
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
),
label: "Writing",
value: totalExamsByModule(stats, "writing"),
tooltip: "The amount of writing exams performed.",
},
{
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
icon: (
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
),
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of speaking exams performed.",
},
{
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
icon: (
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
@@ -93,23 +121,35 @@ export default function Selection({user, page, onStart}: Props) {
<span className="text-mti-gray-taupe">
{page === "exercises" && (
<>
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
acquisition. Your linguistic adventure starts here!
In the realm of language acquisition, practice makes perfect,
and our exercises are the key to unlocking your full potential.
Dive into a world of interactive and engaging exercises that
cater to diverse learning styles. From grammar drills that build
a strong foundation to vocabulary challenges that broaden your
lexicon, our exercises are carefully designed to make learning
English both enjoyable and effective. Whether you&apos;re
looking to reinforce specific skills or embark on a holistic
language journey, our exercises are your companions in the
pursuit of excellence. Embrace the joy of learning as you
navigate through a variety of activities that cater to every
facet of language acquisition. Your linguistic adventure starts
here!
</>
)}
{page === "exams" && (
<>
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language.
Welcome to the heart of success on your English language
journey! Our exams are crafted with precision to assess and
enhance your language skills. Each test is a passport to your
linguistic prowess, designed to challenge and elevate your
abilities. Whether you&apos;re a beginner or a seasoned learner,
our exams cater to all levels, providing a comprehensive
evaluation of your reading, writing, speaking, and listening
skills. Prepare to embark on a journey of self-discovery and
language mastery as you navigate through our thoughtfully
curated exams. Your success is not just a destination; it&apos;s
a testament to your dedication and our commitment to empowering
you with the English language.
</>
)}
</span>
@@ -120,16 +160,24 @@ export default function Selection({user, page, onStart}: Props) {
<div className="flex items-center gap-4">
<div
onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
>
<span className="text-mti-black text-lg font-bold">
Unfinished Sessions
</span>
<BsArrowRepeat
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div>
</div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
{sessions
.sort((a, b) => moment(b.date).diff(moment(a.date)))
.map((session) => (
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
{sessions.map((session) => (
<SessionCard
session={session}
key={session.sessionId}
reload={reload}
loadSession={loadSession}
/>
))}
</span>
</section>
@@ -137,107 +185,163 @@ export default function Selection({user, page, onStart}: Props) {
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("reading")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsBook className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Reading:</span>
<p className="text-left text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
Expand your vocabulary, improve your reading comprehension and
improve your ability to interpret texts in English.
</p>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
{!selectedModules.includes("reading") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("reading")) && (
{selectedModules.includes("reading") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("listening")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsHeadphones className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Listening:</span>
<p className="text-left text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
Improve your ability to follow conversations in English and your
ability to understand different accents and intonations.
</p>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
{!selectedModules.includes("listening") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("listening")) && (
{selectedModules.includes("listening") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("writing")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsPen className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Writing:</span>
<p className="text-left text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
Allow you to practice writing in a variety of formats, from simple
paragraphs to complex essays.
</p>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
{!selectedModules.includes("writing") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("writing")) && (
{selectedModules.includes("writing") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("speaking")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsMegaphone className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Speaking:</span>
<p className="text-left text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
You&apos;ll have access to interactive dialogs, pronunciation
exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
{!selectedModules.includes("speaking") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("speaking")) && (
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
<div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
onClick={
selectedModules.length === 0 || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("level")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsClipboard className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Level:</span>
<p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<p className="text-left text-xs">
You&apos;ll be able to test your english level with multiple
choice questions.
</p>
{!selectedModules.includes("level") &&
selectedModules.length === 0 && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("level")) && (
{selectedModules.includes("level") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && (
{!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
@@ -246,37 +350,53 @@ export default function Selection({user, page, onStart}: Props) {
<div className="flex w-full flex-col items-center gap-3">
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}>
avoidRepeatedExams && "!bg-mti-purple-light "
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
<span
className="tooltip"
data-tip="If possible, the platform will choose exams not yet done."
>
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
onClick={() =>
setVariant((prev) => (prev === "full" ? "partial" : "full"))
}
>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}>
variant === "full" && "!bg-mti-purple-light "
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
</div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
<div
className="tooltip w-full"
data-tip={`Your screen size is too small to do ${page}`}
>
<Button
color="purple"
className="w-full max-w-xs px-12 md:hidden"
disabled
>
Start Exam
</Button>
</div>
@@ -284,7 +404,16 @@ export default function Selection({user, page, onStart}: Props) {
<Button
color="green"
variant={isCompleteExam ? "solid" : "outline"}
onClick={() => isCompleteExam ? setSelectedModules([]) : setSelectedModules(["reading", "listening", "writing", "speaking"])}
onClick={() =>
isCompleteExam
? setSelectedModules([])
: setSelectedModules([
"reading",
"listening",
"writing",
"speaking",
])
}
className="-md:hidden w-full max-w-xs px-12 md:self-end"
>
Complete Exam
@@ -294,12 +423,13 @@ export default function Selection({user, page, onStart}: Props) {
onStart(
selectedModules.sort(sortByModuleName),
avoidRepeatedExams,
variant,
variant
)
}
color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0}>
disabled={selectedModules.length === 0}
>
Start Exam
</Button>
</div>

View File

@@ -3,13 +3,13 @@ import { useEffect, useState } from "react";
const endpoints: Record<string, string> = {
stats: "/api/stats",
training: "/api/training"
training: "/api/training",
};
export default function useFilterRecordsByUser<T extends any[]>(
id?: string,
shouldNotQuery?: boolean,
recordType: string = 'stats'
recordType: string = "stats"
) {
type ElementType = T extends (infer U)[] ? U : never;
@@ -31,7 +31,7 @@ export default function useFilterRecordsByUser<T extends any[]>(
.get<T>(endpoint)
.then((response) => {
// CAUTION: This makes the assumption ElementType has a "user" field that contains the user id
setData(response.data.filter((x: ElementType) => (id ? (x as any).user === id : true)) as T);
setData(response.data);
})
.catch(() => setIsError(true))
.finally(() => setIsLoading(false));
@@ -46,6 +46,6 @@ export default function useFilterRecordsByUser<T extends any[]>(
data,
reload: getData,
isLoading,
isError
isError,
};
}

View File

@@ -17,8 +17,7 @@ export default function usePermissions(user: string) {
.get<Permission[]>(`/api/permissions`)
.then((response) => {
const permissionTypes = response.data
.filter((x) => !x.users.includes(user))
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
.reduce((acc, curr) => curr.users.includes(user)? acc : [...acc, curr.type], [] as PermissionType[]);
setPermissions(permissionTypes);
})
.finally(() => setIsLoading(false));

View File

@@ -1,22 +1,28 @@
import React from "react";
import useTickets from "./useTickets";
import { useState, useEffect } from "react";
import axios from "axios";
const useTicketsListener = (userId?: string) => {
const { tickets, reload } = useTickets();
const [assignedTickets, setAssignedTickets] = useState([]);
React.useEffect(() => {
const getData = () => {
axios
.get("/api/tickets/assignedToUser")
.then((response) => setAssignedTickets(response.data));
};
useEffect(() => {
getData();
}, []);
useEffect(() => {
const intervalId = setInterval(() => {
reload();
getData();
}, 60 * 1000);
return () => clearInterval(intervalId);
}, [reload]);
}, [assignedTickets]);
if (userId) {
const assignedTickets = tickets.filter(
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
);
return {
assignedTickets,
totalAssignedTickets: assignedTickets.length,

27
src/hooks/useUserData.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { useEffect, useState, useCallback } from "react";
import { User } from "../interfaces/user";
import axios from "axios";
export default function useUserData({
userId,
}: {
userId: string;
}) {
const [userData, setUserData] = useState<User | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
if (!userId ) return;
setIsLoading(true);
axios
.get(`/api/users/${userId}`)
.then((response) => setUserData(response.data))
.finally(() => setIsLoading(false))
.catch((error) => setIsError(true));
}, [userId]);
useEffect(getData, [getData]);
return { userData, isLoading, isError, reload: getData };
}

View File

@@ -0,0 +1,99 @@
import Axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { setupCache } from "axios-cache-interceptor";
import Option from "../interfaces/option";
const instance = Axios.create();
const axios = setupCache(instance);
export default function useUsersSelect(props?: {
type?: string;
size?: number;
orderBy?: string;
direction?: "asc" | "desc";
entities?: string[] | string;
}) {
const [inputValue, setInputValue] = useState("");
const [users, setUsers] = useState<Option[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const onScrollLoadMoreOptions = useCallback(() => {
if (users.length === total) return;
const params = new URLSearchParams();
if (!!props)
Object.keys(props).forEach((key) => {
if (props[key as keyof typeof props] !== undefined)
params.append(key, props[key as keyof typeof props]!.toString());
});
setIsLoading(true);
return axios
.get<{ users: Option[]; total: number }>(
`/api/users/search?value=${inputValue}&page=${
page + 1
}&${params.toString()}`,
{ headers: { page: "register" } }
)
.then((response) => {
setPage((curr) => curr + 1);
setTotal(response.data.total);
setUsers((curr) => [...curr, ...response.data.users]);
setIsLoading(false);
return response.data.users;
});
}, [inputValue, page, props, total, users.length]);
const loadOptions = useCallback(
async (inputValue: string,forced?:boolean) => {
let load = true;
setInputValue((currValue) => {
if (!forced&&currValue === inputValue) {
load = false;
return currValue;
}
return inputValue;
});
if (!load) return;
const params = new URLSearchParams();
if (!!props)
Object.keys(props).forEach((key) => {
if (props[key as keyof typeof props] !== undefined)
params.append(key, props[key as keyof typeof props]!.toString());
});
setIsLoading(true);
setPage(0);
return axios
.get<{ users: Option[]; total: number }>(
`/api/users/search?value=${inputValue}&page=0&${params.toString()}`,
{ headers: { page: "register" } }
)
.then((response) => {
setTotal(response.data.total);
setUsers(response.data.users);
setIsLoading(false);
return response.data.users;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[props?.entities, props?.type, props?.size, props?.orderBy, props?.direction]
);
useEffect(() => {
loadOptions("",true);
}, [loadOptions]);
return {
users,
total,
isLoading,
isError,
onScrollLoadMoreOptions,
loadOptions,
inputValue,
};
}

View File

@@ -26,7 +26,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const q = user ? { user: user } : {};
const sessions = await db.collection("sessions").find<Session>({
...q,
}).limit(12).toArray();
}).limit(12).sort({ date: -1 }).toArray();
console.log(sessions)
res.status(200).json(

View File

@@ -15,7 +15,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
const {user} = req.query;
const snapshot = await db.collection("stats").find({ user: user }).toArray();
const snapshot = await db.collection("stats").aggregate([
{ $match: { user: user } },
{ $sort: { "date": 1 } }
]).toArray();
res.status(200).json(snapshot);
}

View File

@@ -0,0 +1,39 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { Ticket, TicketWithCorporate } from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session";
import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
import { Group, CorporateUser } from "@/interfaces/user";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
// specific logic for the preflight request
if (req.method === "OPTIONS") {
res.status(200).end();
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") {
await get(req, res);
}
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id }).toArray();
res.status(200).json(docs);
}

View File

@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { searchUsers } from "@/utils/users.be";
import { Type } from "@/interfaces/user";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user && !req.headers["page"] && req.headers["page"] !== "register") {
res.status(401).json({ ok: false });
return;
}
const {
value,
size,
page,
orderBy = "name",
direction = "asc",
type,
entities
} = req.query as { value?: string, size?: string; type?: Type; page?: string; orderBy?: string; direction?: "asc" | "desc", entities?: string };
const { users, total } = await searchUsers(
value,
size !== undefined ? parseInt(size) : undefined,
page !== undefined ? parseInt(page) : undefined,
{
[orderBy]: direction === "asc" ? 1 : -1,
},
{},
{
...(type ? { "type": type } : {}),
...(entities ? { "entities.id": entities.split(',') } : {})
}
);
res.status(200).json({ users, total });
}

View File

@@ -21,11 +21,9 @@ import useTrainingContentStore from "@/stores/trainingContentStore";
import { Assignment } from "@/interfaces/results";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import useGradingSystem from "@/hooks/useGrading";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be";
import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
@@ -40,14 +38,16 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (shouldRedirectHome(user)) return redirect("/")
const entityIDs = mapBy(user.entities, 'id')
const isAdmin = checkAccess(user, ["admin", "developer"])
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id')))
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id'))
const entitiesIds = mapBy(entities, 'id')
const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds))
const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds))
const gradingSystems = await getGradingSystemByEntities(entitiesIds)
return {
props: serialize({ user, users, assignments, entities, gradingSystems }),
props: serialize({ user, users, assignments, entities, gradingSystems,isAdmin }),
};
}, sessionOptions);
@@ -59,11 +59,12 @@ interface Props {
assignments: Assignment[];
entities: EntityWithRoles[]
gradingSystems: Grading[]
isAdmin:boolean
}
const MAX_TRAINING_EXAMS = 10;
export default function History({ user, users, assignments, entities, gradingSystems }: Props) {
export default function History({ user, users, assignments, entities, gradingSystems,isAdmin }: Props) {
const router = useRouter();
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
state.selectedUser,
@@ -193,7 +194,7 @@ export default function History({ user, users, assignments, entities, gradingSys
<ToastContainer />
{user && (
<Layout user={user}>
<RecordFilter user={user} users={users} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}>
<RecordFilter user={user} isAdmin={isAdmin} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}>
{training && (
<div className="flex flex-row">
<div className="font-semibold text-2xl mr-4">

View File

@@ -1,106 +1,172 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { BsArrowClockwise, BsChevronLeft, BsChevronRight, BsFileEarmarkText, BsPencil, BsStar } from "react-icons/bs";
import { LinearScale, Chart as ChartJS, CategoryScale, PointElement, LineElement, Legend, Tooltip, LineController } from "chart.js";
import {
BsArrowClockwise,
BsChevronLeft,
BsChevronRight,
BsFileEarmarkText,
BsPencil,
BsStar,
} from "react-icons/bs";
import {
LinearScale,
Chart as ChartJS,
CategoryScale,
PointElement,
LineElement,
Legend,
Tooltip,
LineController,
} from "chart.js";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { useEffect, useMemo, useState } from "react";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate } from "@/utils/stats";
import useUser from "@/hooks/useUser";
import {
averageScore,
groupBySession,
groupByModule,
timestampToMoment,
} from "@/utils/stats";
import { ToastContainer } from "react-toastify";
import { capitalize, Dictionary } from "lodash";
import { capitalize } from "lodash";
import { Module } from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
import { calculateBandScore } from "@/utils/score";
import {
countExamModules,
countFullExams,
MODULE_ARRAY,
sortByModule,
} from "@/utils/moduleUtils";
import { Chart } from "react-chartjs-2";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import DatePicker from "react-datepicker";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import ProfileSummary from "@/components/ProfileSummary";
import moment from "moment";
import { Group, Stat, User } from "@/interfaces/user";
import { Stat, User } from "@/interfaces/user";
import { Divider } from "primereact/divider";
import Badge from "@/components/Low/Badge";
import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntities } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import Select from "@/components/Low/Select";
import { requestUser } from "@/utils/api";
import useUserData from "../hooks/useUserData";
import useUsersSelect from "../hooks/useUsersSelect";
import AsyncSelect from "../components/Low/AsyncSelect";
ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip);
ChartJS.register(
LinearScale,
CategoryScale,
PointElement,
LineElement,
LineController,
Legend,
Tooltip
);
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
const entityIDs = mapBy(user.entities, "id");
const isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntities(isAdmin ? undefined : entityIDs, {
id: 1,
label: 1,
});
return {
props: serialize({ user, entities, users, groups }),
props: serialize({ user, entities, isAdmin }),
};
}, sessionOptions);
interface Props {
user: User
users: User[]
entities: EntityWithRoles[]
groups: Group[]
user: User;
entities: EntityWithRoles[];
isAdmin: boolean;
}
export default function Stats({ user, entities, users, groups }: Props) {
export default function Stats({ user, entities, isAdmin }: Props) {
const [statsUserId, setStatsUserId] = useState<string>(user.id);
const [startDate, setStartDate] = useState<Date | null>(moment(new Date()).subtract(1, "weeks").toDate());
const [endDate, setEndDate] = useState<Date | null>(new Date());
const [initialStatDate, setInitialStatDate] = useState<Date>();
const [selectedEntity, setSelectedEntity] = useState<string>()
const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState<Date | null>(new Date());
const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState<Date | null>(new Date());
const { userData } = useUserData({ userId: statsUserId });
const [startDate, setStartDate] = useState<Date | null>(
moment(new Date()).subtract(1, "weeks").toDate()
);
const [endDate, setEndDate] = useState<Date | null>(new Date());
const [selectedEntity, setSelectedEntity] = useState<string>();
const entitiesToSearch = useMemo(() => {
if(selectedEntity) return [selectedEntity]
if (isAdmin) return undefined;
return mapBy(entities, "id");
}, [entities, isAdmin, selectedEntity]);
const {
users: students,
isLoading,
onScrollLoadMoreOptions,
loadOptions,
} = useUsersSelect({
type: "student",
size: 50,
orderBy: "name",
direction: "asc",
entities: entitiesToSearch,
});
const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] =
useState<Date | null>(new Date());
const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] =
useState<Date | null>(new Date());
const [dailyScoreDate, setDailyScoreDate] = useState<Date | null>(new Date());
const [intervalDates, setIntervalDates] = useState<Date[]>([]);
const { data: stats } = useFilterRecordsByUser<Stat[]>(statsUserId, !statsUserId);
const students = useMemo(() =>
filterBy(users, 'type', 'student').filter(x => !selectedEntity ? true : mapBy(x.entities, 'id').includes((selectedEntity))),
[users, selectedEntity]
)
useEffect(() => {
setInitialStatDate(
stats
.filter((s) => s.date)
.sort((a, b) => timestampToMoment(a).diff(timestampToMoment(b)))
.map(timestampToMoment)
.shift()
?.toDate(),
const { data: stats } = useFilterRecordsByUser<Stat[]>(
statsUserId,
!statsUserId
);
const initialStatDate = useMemo(
() => (stats[0] ? timestampToMoment(stats[0]).toDate() : null),
[stats]
);
}, [stats]);
const calculateModuleScore = (stats: Stat[]) => {
const moduleStats = groupByModule(stats);
return Object.keys(moduleStats).map((y) => {
const correct = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.total, 0);
const correct = moduleStats[y].reduce(
(accumulator, current) => accumulator + current.score.correct,
0
);
const total = moduleStats[y].reduce(
(accumulator, current) => accumulator + current.score.total,
0
);
return {
module: y as Module,
score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"),
score: calculateBandScore(
correct,
total,
y as Module,
user?.focus || "academic"
),
};
});
};
@@ -111,10 +177,21 @@ export default function Stats({ user, entities, users, groups }: Props) {
const session = groupedBySession[x];
const moduleStats = groupByModule(session);
if (!Object.keys(moduleStats).includes(module)) return null;
const correct = moduleStats[module].reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats[module].reduce((acc, curr) => acc + curr.score.total, 0);
const correct = moduleStats[module].reduce(
(acc, curr) => acc + curr.score.correct,
0
);
const total = moduleStats[module].reduce(
(acc, curr) => acc + curr.score.total,
0
);
return calculateBandScore(correct, total, module, user?.focus || "academic");
return calculateBandScore(
correct,
total,
module,
user?.focus || "academic"
);
});
return sessionAverage;
@@ -139,7 +216,9 @@ export default function Stats({ user, entities, users, groups }: Props) {
const calculateTotalScore = (stats: Stat[], divisionFactor: number) => {
const moduleScores = calculateModuleScore(stats);
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / divisionFactor;
return (
moduleScores.reduce((acc, curr) => acc + curr.score, 0) / divisionFactor
);
};
const calculateScorePerModule = (stats: Stat[], module: Module) => {
@@ -162,22 +241,29 @@ export default function Stats({ user, entities, users, groups }: Props) {
{user && (
<Layout user={user} className="gap-8">
<ProfileSummary
user={users.find((x) => x.id === statsUserId) || user}
user={userData || user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
icon: (
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: countFullExams(stats),
label: "Exams",
tooltip: "Number of all conducted completed exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
icon: (
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: countExamModules(stats),
label: "Modules",
tooltip: "Number of all exam modules performed including Level Test",
tooltip:
"Number of all exam modules performed including Level Test",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
icon: (
<BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
tooltip: "Average success rate for questions responded",
@@ -187,21 +273,39 @@ export default function Stats({ user, entities, users, groups }: Props) {
<section className="flex flex-col gap-3">
<div className="w-full flex justify-between gap-4 items-center">
{["corporate", "teacher", "mastercorporate", "developer", "admin"].includes(user.type) && (
{[
"corporate",
"teacher",
"mastercorporate",
"developer",
"admin",
].includes(user.type) && (
<>
<Select
className="w-full"
options={entities.map(e => ({ value: e.id, label: e.label }))}
onChange={(value) => setSelectedEntity(value?.value || undefined)}
options={entities.map((e) => ({
value: e.id,
label: e.label,
}))}
onChange={(value) =>
setSelectedEntity(value?.value || undefined)
}
placeholder="Select an entity..."
isClearable
/>
<Select
<AsyncSelect
className="w-full"
options={students
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` }))}
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
onChange={(value) => setStatsUserId(value?.value || user.id)}
options={students}
isLoading={isLoading}
defaultValue={{
value: user.id,
label: `${user.name} - ${user.email}`,
}}
onChange={(value) =>
setStatsUserId(value?.value || user.id)
}
loadOptions={loadOptions}
onMenuScrollToBottom={onScrollLoadMoreOptions}
/>
</>
)}
@@ -213,13 +317,18 @@ export default function Stats({ user, entities, users, groups }: Props) {
{/* Overall Level per Month */}
<div className="flex flex-col items-center gap-4 border w-full h-[420px] overflow-y-scroll scrollbar-hide md:max-w-sm border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Overall Level per Month</span>
<span className="text-sm font-bold">
Overall Level per Month
</span>
<div className="flex gap-2 items-center">
{monthlyOverallScoreDate && (
<button
onClick={() =>
setMonthlyOverallScoreDate((prev) => moment(prev).subtract(1, "months").toDate())
}>
setMonthlyOverallScoreDate((prev) =>
moment(prev).subtract(1, "months").toDate()
)
}
>
<BsChevronLeft />
</button>
)}
@@ -234,13 +343,22 @@ export default function Stats({ user, entities, users, groups }: Props) {
/>
{monthlyOverallScoreDate && (
<button
disabled={moment(monthlyOverallScoreDate).add(1, "months").isAfter(moment())}
onClick={() => setMonthlyOverallScoreDate((prev) => moment(prev).add(1, "months").toDate())}
className="disabled:text-neutral-200">
disabled={moment(monthlyOverallScoreDate)
.add(1, "months")
.isAfter(moment())}
onClick={() =>
setMonthlyOverallScoreDate((prev) =>
moment(prev).add(1, "months").toDate()
)
}
className="disabled:text-neutral-200"
>
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyOverallScoreDate(new Date())}>
<button
onClick={() => setMonthlyOverallScoreDate(new Date())}
>
<BsArrowClockwise />
</button>
</div>
@@ -248,23 +366,28 @@ export default function Stats({ user, entities, users, groups }: Props) {
<div className="w-full grid grid-cols-3 gap-4 items-center">
{[...Array(31).keys()].map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
"DD/MM/yyyy"
);
return date.isValid() && date.isSameOrBefore(moment()) ? (
return date.isValid() &&
date.isSameOrBefore(moment()) ? (
<div
key={day}
className="flex flex-col gap-1 items-start border border-mti-gray-smoke rounded-lg overflow-hidden">
className="flex flex-col gap-1 items-start border border-mti-gray-smoke rounded-lg overflow-hidden"
>
<span className="bg-mti-purple-ultralight w-full px-2 py-1 font-semibold">
Day {(day + 1).toString().padStart(2, "0")}
</span>
<span className="px-2">
Level{" "}
{calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date)),
5,
stats.filter((s) =>
timestampToMoment(s).isBefore(date)
),
5
).toFixed(1)}
</span>
</div>
@@ -276,13 +399,18 @@ export default function Stats({ user, entities, users, groups }: Props) {
{/* Overall Level per Month Graph */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-[420px]">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Overall Level per Month</span>
<span className="text-sm font-bold">
Overall Level per Month
</span>
<div className="flex gap-2 items-center">
{monthlyOverallScoreDate && (
<button
onClick={() =>
setMonthlyOverallScoreDate((prev) => moment(prev).subtract(1, "months").toDate())
}>
setMonthlyOverallScoreDate((prev) =>
moment(prev).subtract(1, "months").toDate()
)
}
>
<BsChevronLeft />
</button>
)}
@@ -297,13 +425,22 @@ export default function Stats({ user, entities, users, groups }: Props) {
/>
{monthlyOverallScoreDate && (
<button
disabled={moment(monthlyOverallScoreDate).add(1, "months").isAfter(moment())}
onClick={() => setMonthlyOverallScoreDate((prev) => moment(prev).add(1, "months").toDate())}
className="disabled:text-neutral-200">
disabled={moment(monthlyOverallScoreDate)
.add(1, "months")
.isAfter(moment())}
onClick={() =>
setMonthlyOverallScoreDate((prev) =>
moment(prev).add(1, "months").toDate()
)
}
className="disabled:text-neutral-200"
>
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyOverallScoreDate(new Date())}>
<button
onClick={() => setMonthlyOverallScoreDate(new Date())}
>
<BsArrowClockwise />
</button>
</div>
@@ -322,11 +459,14 @@ export default function Stats({ user, entities, users, groups }: Props) {
labels: [...Array(31).keys()]
.map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
"DD/MM/yyyy"
);
return date.isValid() ? (day + 1).toString().padStart(2, "0") : undefined;
return date.isValid()
? (day + 1).toString().padStart(2, "0")
: undefined;
})
.filter((x) => !!x),
datasets: [
@@ -341,15 +481,22 @@ export default function Stats({ user, entities, users, groups }: Props) {
data: [...Array(31).keys()]
.map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get(
"month"
) + 1
}/${moment(monthlyOverallScoreDate).get(
"year"
)}`,
"DD/MM/yyyy"
);
return date.isValid()
? calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date)),
5,
stats.filter((s) =>
timestampToMoment(s).isBefore(date)
),
5
).toFixed(1)
: undefined;
})
@@ -363,13 +510,18 @@ export default function Stats({ user, entities, users, groups }: Props) {
{/* Module Level per Day */}
<div className="flex flex-col gap-8 border w-full h-fit md:h-[420px] md:max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Module Level per Day</span>
<span className="text-sm font-bold">
Module Level per Day
</span>
<div className="flex gap-2 items-center">
{monthlyModuleScoreDate && (
<button
onClick={() =>
setMonthlyModuleScoreDate((prev) => moment(prev).subtract(1, "days").toDate())
}>
setMonthlyModuleScoreDate((prev) =>
moment(prev).subtract(1, "days").toDate()
)
}
>
<BsChevronLeft />
</button>
)}
@@ -383,27 +535,45 @@ export default function Stats({ user, entities, users, groups }: Props) {
/>
{monthlyModuleScoreDate && (
<button
disabled={moment(monthlyModuleScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setMonthlyModuleScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
disabled={moment(monthlyModuleScoreDate)
.add(1, "days")
.isAfter(moment())}
onClick={() =>
setMonthlyModuleScoreDate((prev) =>
moment(prev).add(1, "days").toDate()
)
}
className="disabled:text-neutral-200"
>
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyModuleScoreDate(new Date())}>
<button
onClick={() => setMonthlyModuleScoreDate(new Date())}
>
<BsArrowClockwise />
</button>
</div>
</div>
<div className="flex flex-col gap-4">
{calculateModuleScore(stats.filter((s) => timestampToMoment(s).isBefore(moment(monthlyModuleScoreDate))))
{calculateModuleScore(
stats.filter((s) =>
timestampToMoment(s).isBefore(
moment(monthlyModuleScoreDate)
)
)
)
.sort(sortByModule)
.map(({ module, score }) => (
<div className="flex flex-col gap-2" key={module}>
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{score}</span> of <span className="font-medium">9</span>
<span className="font-medium">{score}</span> of{" "}
<span className="font-medium">9</span>
</span>
<span className="text-xs">
{capitalize(module)}
</span>
<span className="text-xs">{capitalize(module)}</span>
</div>
<ProgressBar
color={module as Module}
@@ -423,10 +593,18 @@ export default function Stats({ user, entities, users, groups }: Props) {
{/* Module Level per Exam */}
<div className="flex flex-col items-center gap-4 border w-full h-[420px] overflow-y-scroll scrollbar-hide md:max-w-sm border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Module Level per Exam</span>
<span className="text-sm font-bold">
Module Level per Exam
</span>
<div className="flex gap-2 items-center">
{dailyScoreDate && (
<button onClick={() => setDailyScoreDate((prev) => moment(prev).subtract(1, "days").toDate())}>
<button
onClick={() =>
setDailyScoreDate((prev) =>
moment(prev).subtract(1, "days").toDate()
)
}
>
<BsChevronLeft />
</button>
)}
@@ -440,9 +618,16 @@ export default function Stats({ user, entities, users, groups }: Props) {
/>
{dailyScoreDate && (
<button
disabled={moment(dailyScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setDailyScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
disabled={moment(dailyScoreDate)
.add(1, "days")
.isAfter(moment())}
onClick={() =>
setDailyScoreDate((prev) =>
moment(prev).add(1, "days").toDate()
)
}
className="disabled:text-neutral-200"
>
<BsChevronRight />
</button>
)}
@@ -457,22 +642,41 @@ export default function Stats({ user, entities, users, groups }: Props) {
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
).length === 0 && <span className="font-semibold ml-1">No exams performed this day...</span>}
Math.abs(
timestampToMoment(s).diff(
moment(dailyScoreDate),
"days"
)
) === 0 &&
timestampToMoment(s).day() ===
moment(dailyScoreDate).day()
)
)
).length === 0 && (
<span className="font-semibold ml-1">
No exams performed this day...
</span>
)}
{Object.keys(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
Math.abs(
timestampToMoment(s).diff(
moment(dailyScoreDate),
"days"
)
) === 0 &&
timestampToMoment(s).day() ===
moment(dailyScoreDate).day()
)
)
).map((session, index) => (
<div key={index} className="flex flex-col gap-2 items-start rounded-lg overflow-hidden">
<div
key={index}
className="flex flex-col gap-2 items-start rounded-lg overflow-hidden"
>
<span className="bg-mti-purple-ultralight w-full px-2 py-1 font-semibold">
Exam {(index + 1).toString().padStart(2, "0")}
</span>
@@ -482,13 +686,22 @@ export default function Stats({ user, entities, users, groups }: Props) {
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0,
),
Math.abs(
timestampToMoment(s).diff(
moment(dailyScoreDate),
"days"
)
) === 0
)
)[session],
module,
module
);
return score === -1 ? null : <Badge module={module}>{score.toFixed(1)}</Badge>;
return score === -1 ? null : (
<Badge module={module}>
{score.toFixed(1)}
</Badge>
);
}).filter((m) => !!m)}
</div>
</div>
@@ -498,10 +711,18 @@ export default function Stats({ user, entities, users, groups }: Props) {
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-[420px]">
<div className="flex flex-col gap-2 w-full mb-2">
<span className="text-sm font-bold">Module Level per Exam</span>
<span className="text-sm font-bold">
Module Level per Exam
</span>
<div className="flex gap-2 items-center">
{dailyScoreDate && (
<button onClick={() => setDailyScoreDate((prev) => moment(prev).subtract(1, "days").toDate())}>
<button
onClick={() =>
setDailyScoreDate((prev) =>
moment(prev).subtract(1, "days").toDate()
)
}
>
<BsChevronLeft />
</button>
)}
@@ -515,9 +736,16 @@ export default function Stats({ user, entities, users, groups }: Props) {
/>
{dailyScoreDate && (
<button
disabled={moment(dailyScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setDailyScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
disabled={moment(dailyScoreDate)
.add(1, "days")
.isAfter(moment())}
onClick={() =>
setDailyScoreDate((prev) =>
moment(prev).add(1, "days").toDate()
)
}
className="disabled:text-neutral-200"
>
<BsChevronRight />
</button>
)}
@@ -541,11 +769,20 @@ export default function Stats({ user, entities, users, groups }: Props) {
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
Math.abs(
timestampToMoment(s).diff(
moment(dailyScoreDate),
"days"
)
) === 0 &&
timestampToMoment(s).day() ===
moment(dailyScoreDate).day()
)
)
).map(
(_, index) =>
`Exam ${(index + 1).toString().padStart(2, "0")}`
),
),
).map((_, index) => `Exam ${(index + 1).toString().padStart(2, "0")}`),
datasets: [
...MODULE_ARRAY.map((module, index) => ({
type: "line" as const,
@@ -556,10 +793,16 @@ export default function Stats({ user, entities, users, groups }: Props) {
data: calculateModularScorePerSession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
Math.abs(
timestampToMoment(s).diff(
moment(dailyScoreDate),
"days"
)
) === 0 &&
timestampToMoment(s).day() ===
moment(dailyScoreDate).day()
),
module,
module
),
})),
],
@@ -578,7 +821,9 @@ export default function Stats({ user, entities, users, groups }: Props) {
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date) => moment(date).isSameOrBefore(moment(new Date()))}
filterDate={(date) =>
moment(date).isSameOrBefore(moment(new Date()))
}
onChange={([initialDate, finalDate]) => {
setStartDate(initialDate);
setEndDate(finalDate);
@@ -589,8 +834,11 @@ export default function Stats({ user, entities, users, groups }: Props) {
{MODULE_ARRAY.map((module, index) => (
<div
className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96"
key={module}>
<span className="text-sm font-bold">{capitalize(module)} Score Band in Interval</span>
key={module}
>
<span className="text-sm font-bold">
{capitalize(module)} Score Band in Interval
</span>
<Chart
options={{
scales: {
@@ -602,7 +850,9 @@ export default function Stats({ user, entities, users, groups }: Props) {
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
labels: intervalDates.map((date) =>
moment(date).format("DD/MM/YYYY")
),
datasets: [
{
type: "line",
@@ -615,9 +865,11 @@ export default function Stats({ user, entities, users, groups }: Props) {
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === module,
(s) =>
timestampToMoment(s).isBefore(date) &&
s.module === module
),
1,
1
).toFixed(1);
}),
},

View File

@@ -189,7 +189,7 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
</div>
) : (
<>
<RecordFilter users={users} entities={entities} user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}>
<RecordFilter entities={entities} user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}>
{user.type === "student" && (
<>
<div className="flex items-center">

View File

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

View File

@@ -11,14 +11,64 @@ import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB);
export async function getUsers(filter?: object, limit = 0, sort = {}) {
export async function getUsers(filter?: object, limit = 0, sort = {}, projection = {}) {
return await db
.collection("users")
.find<User>(filter || {}, { projection: { _id: 0 } })
.find<User>(filter || {}, { projection: { _id: 0, ...projection } })
.limit(limit)
.sort(sort)
.toArray();
}
export async function searchUsers(searchInput?: string, limit = 50, page = 0, sort: object = { "name": 1 }, projection = {}, filter?: object) {
const compoundFilter = {
"compound": {
"should": [...(searchInput != "" ? [{
"autocomplete": {
"query": searchInput,
"path": "name",
}
}, {
"autocomplete": {
"query": searchInput,
"path": "email",
}
}] : [{ exists: { path: "name" } }])],
"minimumShouldMatch": 1,
}
}
const [{ users, totalUsers }] = await db
.collection("users").aggregate([
{
"$search": {
"index": "UsersByNameEmail",
...compoundFilter
}
},
...(filter ? [{
"$match": Object.entries(filter).reduce((accm, [key, value]) => {
if (value.length === 0) return accm
accm[key] = Array.isArray(value) ? { $in: value } : value
return accm;
}, {} as any)
}] : []),
{
$facet: {
totalUsers: [{ $count: "count" }],
users: [
{ $sort: sort },
{ $skip: limit * page },
{ $limit: limit },
{ "$project": { _id: 0, label: { $concat: ["$name", " - ", "$email"] }, value: "$id", ...projection } }
]
}
}
]).toArray();
return { users, total: totalUsers[0]?.count || 0 };
}
export async function countUsers(filter?: object) {
return await db
.collection("users")
@@ -67,10 +117,10 @@ export async function countEntityUsers(id: string, filter?: object) {
return await db.collection("users").countDocuments({ "entities.id": id, ...(filter || {}) });
}
export async function getEntitiesUsers(ids: string[], filter?: object, limit?: number) {
export async function getEntitiesUsers(ids: string[], filter?: object, limit?: number, projection = {}) {
return await db
.collection("users")
.find<User>({ "entities.id": { $in: ids }, ...(filter || {}) })
.find<User>({ "entities.id": { $in: ids }, ...(filter || {}) }, projection)
.limit(limit || 0)
.toArray();
}