ENCOA-315: Small fix and merge
This commit is contained in:
117
src/components/Low/AsyncSelect.tsx
Normal file
117
src/components/Low/AsyncSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {useMemo, useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
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 {totalExamsByModule} from "@/utils/stats";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
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 { sortByModuleName } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import { Variant } from "@/interfaces/exam";
|
||||
import useSessions, { Session } from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import moment from "moment";
|
||||
@@ -21,30 +29,40 @@ 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) {
|
||||
export default function Selection({ user, page, onStart }: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
|
||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||
const { data: stats } = useFilterRecordsByUser<Stat[]>(user?.id);
|
||||
const { sessions, isLoading, reload } = useSessions(user.id);
|
||||
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
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'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'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'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'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'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'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'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
You'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'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'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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function useFilterRecordsByUser<T extends any[]>(
|
||||
|
||||
const endpointURL = endpoints[recordType] || endpoints.stats;
|
||||
// CAUTION: This makes the assumption that the record enpoint has a /user/${id} endpoint
|
||||
const endpoint = !id ? endpointURL: `${endpointURL}/user/${id}`;
|
||||
const endpoint = !id ? endpointURL : `${endpointURL}/user/${id}`;
|
||||
|
||||
const getData = () => {
|
||||
if (shouldNotQuery) return;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
27
src/hooks/useUserData.tsx
Normal 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 };
|
||||
}
|
||||
99
src/hooks/useUsersSelect.tsx
Normal file
99
src/hooks/useUsersSelect.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
||||
dispatch({ type: "UPDATE_EXAMS" });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flags.finalizeExam]);
|
||||
}, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]);
|
||||
|
||||
|
||||
const aggregateScoresByModule = (isPractice?: boolean): {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
39
src/pages/api/tickets/assignedToUser/index.ts
Normal file
39
src/pages/api/tickets/assignedToUser/index.ts
Normal 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);
|
||||
}
|
||||
39
src/pages/api/users/search.ts
Normal file
39
src/pages/api/users/search.ts
Normal 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 });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -42,15 +40,17 @@ 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)
|
||||
const pendingSessionIds = await getPendingEvals(user.id);
|
||||
|
||||
return {
|
||||
props: serialize({ user, users, assignments, entities, gradingSystems, pendingSessionIds }),
|
||||
props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
@@ -63,11 +63,12 @@ interface Props {
|
||||
entities: EntityWithRoles[]
|
||||
gradingSystems: Grading[]
|
||||
pendingSessionIds: string[];
|
||||
isAdmin:boolean
|
||||
}
|
||||
|
||||
const MAX_TRAINING_EXAMS = 10;
|
||||
|
||||
export default function History({ user, users, assignments, entities, gradingSystems, pendingSessionIds }: Props) {
|
||||
export default function History({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }: Props) {
|
||||
const router = useRouter();
|
||||
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
|
||||
state.selectedUser,
|
||||
@@ -201,7 +202,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">
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user