ENCOA-314 :

- Implemented Async Select
- Changed Stats Page User fetching to use Async Select and only fetch the User data when it needs
- Changed Record Filter to use Async Select
- Changed useTicketListener to only fetch needed data
- Added Sort/Projection to remove unnecessary data processing.
- Removed some unnecessary data processing.
This commit is contained in:
José Marques Lima
2025-01-20 02:52:39 +00:00
parent 205449e1ae
commit ae9a49681e
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,165 +9,216 @@ import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import useUsersSelect from "../../hooks/useUsersSelect";
import AsyncSelect from "../Low/AsyncSelect";
type TimeFilter = "months" | "weeks" | "days"; type TimeFilter = "months" | "weeks" | "days";
type Filter = TimeFilter | "assignments" | undefined; type Filter = TimeFilter | "assignments" | undefined;
interface Props { interface Props {
user: User; user: User;
entities: EntityWithRoles[] entities: EntityWithRoles[];
users: User[] isAdmin?: boolean;
filterState: { filterState: {
filter: Filter, filter: Filter;
setFilter: React.Dispatch<React.SetStateAction<Filter>> setFilter: React.Dispatch<React.SetStateAction<Filter>>;
}, };
assignments?: boolean; assignments?: boolean;
children?: ReactNode children?: ReactNode;
} }
const defaultSelectableCorporate = { const defaultSelectableCorporate = {
value: "", value: "",
label: "All", label: "All",
}; };
const RecordFilter: React.FC<Props> = ({ const RecordFilter: React.FC<Props> = ({
user, user,
entities, entities,
users, filterState,
filterState, assignments = true,
assignments = true, isAdmin = false,
children children,
}) => { }) => {
const { filter, setFilter } = filterState; const { filter, setFilter } = filterState;
const [entity, setEntity] = useState<string>() const [entity, setEntity] = useState<string>();
const [, setStatsUserId] = useRecordStore((state) => [ const [, setStatsUserId] = useRecordStore((state) => [
state.selectedUser, state.selectedUser,
state.setSelectedUser state.setSelectedUser,
]); ]);
const 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"
);
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
setFilter((prev) => (prev === value ? undefined : value));
};
return ( const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center"> setFilter((prev) => (prev === value ? undefined : value));
<div className="xl:w-3/4 flex gap-2"> };
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
<>
<div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select return (
options={allowedViewEntities.map((e) => ({ value: e.id, label: e.label }))} <div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
onChange={(value) => setEntity(value?.value || undefined)} <div className="xl:w-3/4 flex gap-2">
isClearable {checkAccess(user, ["developer", "admin", "mastercorporate"]) &&
styles={{ !children && (
menuPortal: (base) => ({ ...base, zIndex: 9999 }), <>
option: (styles, state) => ({ <div className="flex flex-col gap-2 w-full">
...styles, <label className="font-normal text-base text-mti-gray-dim">
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", Entity
color: state.isFocused ? "black" : styles.color, </label>
}),
}} />
</div>
<div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select <Select
options={entityUsers.map((x) => ({ options={allowedViewEntities.map((e) => ({
value: x.id, value: e.id,
label: `${x.name} - ${x.email}`, label: e.label,
}))} }))}
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} onChange={(value) => setEntity(value?.value || undefined)}
onChange={(value) => setStatsUserId(value?.value!)} isClearable
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused
color: state.isFocused ? "black" : styles.color, ? "#D5D9F0"
}), : state.isSelected
}} ? "#7872BF"
/> : "white",
</div> color: state.isFocused ? "black" : styles.color,
</> }),
)} }}
{(user.type === "corporate" || user.type === "teacher") && !children && ( />
<div className="flex flex-col gap-2"> </div>
<label className="font-normal text-base text-mti-gray-dim">User</label> <div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">
User
</label>
<Select <AsyncSelect
options={users isLoading={isLoading}
.map((x) => ({ loadOptions={loadOptions}
value: x.id, onMenuScrollToBottom={onScrollLoadMoreOptions}
label: `${x.name} - ${x.email}`, options={users}
}))} defaultValue={{
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} value: user.id,
onChange={(value) => setStatsUserId(value?.value!)} label: `${user.name} - ${user.email}`,
styles={{ }}
menuPortal: (base) => ({ ...base, zIndex: 9999 }), onChange={(value) => setStatsUserId(value?.value!)}
option: (styles, state) => ({ styles={{
...styles, menuPortal: (base) => ({ ...base, zIndex: 9999 }),
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", option: (styles, state) => ({
color: state.isFocused ? "black" : styles.color, ...styles,
}), backgroundColor: state.isFocused
}} ? "#D5D9F0"
/> : state.isSelected
</div> ? "#7872BF"
)} : "white",
{children} color: state.isFocused ? "black" : styles.color,
</div> }),
<div className="flex gap-4 w-full justify-center xl:justify-end"> }}
{assignments && ( />
<button </div>
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", {(user.type === "corporate" || user.type === "teacher") &&
filter === "assignments" && "!bg-mti-purple-light !text-white", !children && (
)} <div className="flex flex-col gap-2">
onClick={() => toggleFilter("assignments")}> <label className="font-normal text-base text-mti-gray-dim">
Assignments User
</button> </label>
)}
<button <AsyncSelect
className={clsx( isLoading={isLoading}
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", loadOptions={loadOptions}
"transition duration-300 ease-in-out", onMenuScrollToBottom={onScrollLoadMoreOptions}
filter === "months" && "!bg-mti-purple-light !text-white", options={users}
)} defaultValue={{
onClick={() => toggleFilter("months")}> value: user.id,
Last month label: `${user.name} - ${user.email}`,
</button> }}
<button onChange={(value) => setStatsUserId(value?.value!)}
className={clsx( styles={{
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", menuPortal: (base) => ({ ...base, zIndex: 9999 }),
"transition duration-300 ease-in-out", option: (styles, state) => ({
filter === "weeks" && "!bg-mti-purple-light !text-white", ...styles,
)} backgroundColor: state.isFocused
onClick={() => toggleFilter("weeks")}> ? "#D5D9F0"
Last week : state.isSelected
</button> ? "#7872BF"
<button : "white",
className={clsx( color: state.isFocused ? "black" : styles.color,
"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", />
)} </div>
onClick={() => toggleFilter("days")}> )}
Last day {children}
</button> </div>
</div> <div className="flex gap-4 w-full justify-center xl:justify-end">
</div> {assignments && (
); <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 === "assignments" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("assignments")}
>
Assignments
</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 === "months" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("months")}
>
Last month
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("weeks")}
>
Last week
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white"
)}
onClick={() => toggleFilter("days")}
>
Last day
</button>
</div>
</div>
);
};
export default RecordFilter; export default RecordFilter;

View File

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

View File

@@ -3,13 +3,13 @@ import { useEffect, useState } from "react";
const endpoints: Record<string, string> = { const endpoints: Record<string, string> = {
stats: "/api/stats", stats: "/api/stats",
training: "/api/training" training: "/api/training",
}; };
export default function useFilterRecordsByUser<T extends any[]>( export default function useFilterRecordsByUser<T extends any[]>(
id?: string, id?: string,
shouldNotQuery?: boolean, shouldNotQuery?: boolean,
recordType: string = 'stats' recordType: string = "stats"
) { ) {
type ElementType = T extends (infer U)[] ? U : never; 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; const endpointURL = endpoints[recordType] || endpoints.stats;
// CAUTION: This makes the assumption that the record enpoint has a /user/${id} endpoint // 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 = () => { const getData = () => {
if (shouldNotQuery) return; if (shouldNotQuery) return;
@@ -31,7 +31,7 @@ export default function useFilterRecordsByUser<T extends any[]>(
.get<T>(endpoint) .get<T>(endpoint)
.then((response) => { .then((response) => {
// CAUTION: This makes the assumption ElementType has a "user" field that contains the user id // 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)) .catch(() => setIsError(true))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
@@ -42,10 +42,10 @@ export default function useFilterRecordsByUser<T extends any[]>(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, shouldNotQuery, recordType, endpoint]); }, [id, shouldNotQuery, recordType, endpoint]);
return { return {
data, data,
reload: getData, reload: getData,
isLoading, isLoading,
isError isError,
}; };
} }

View File

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

View File

@@ -1,22 +1,28 @@
import React from "react"; import { useState, useEffect } from "react";
import useTickets from "./useTickets"; import axios from "axios";
const useTicketsListener = (userId?: string) => { 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(() => { const intervalId = setInterval(() => {
reload(); getData();
}, 60 * 1000); }, 60 * 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [reload]); }, [assignedTickets]);
if (userId) { if (userId) {
const assignedTickets = tickets.filter(
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
);
return { return {
assignedTickets, assignedTickets,
totalAssignedTickets: assignedTickets.length, 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 q = user ? { user: user } : {};
const sessions = await db.collection("sessions").find<Session>({ const sessions = await db.collection("sessions").find<Session>({
...q, ...q,
}).limit(12).toArray(); }).limit(12).sort({ date: -1 }).toArray();
console.log(sessions) console.log(sessions)
res.status(200).json( res.status(200).json(

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -189,7 +189,7 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
</div> </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" && ( {user.type === "student" && (
<> <>
<div className="flex items-center"> <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) || [] })); 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 return await db
.collection("entities") .collection("entities")
.find<Entity>(ids ? { id: { $in: ids } } : {}) .find<Entity>(ids ? { id: { $in: ids } } : {}, projection)
.toArray(); .toArray();
}; };

View File

@@ -11,14 +11,64 @@ import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB); 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 return await db
.collection("users") .collection("users")
.find<User>(filter || {}, { projection: { _id: 0 } }) .find<User>(filter || {}, { projection: { _id: 0, ...projection } })
.limit(limit) .limit(limit)
.sort(sort) .sort(sort)
.toArray(); .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) { export async function countUsers(filter?: object) {
return await db return await db
.collection("users") .collection("users")
@@ -67,10 +117,10 @@ export async function countEntityUsers(id: string, filter?: object) {
return await db.collection("users").countDocuments({ "entities.id": id, ...(filter || {}) }); 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 return await db
.collection("users") .collection("users")
.find<User>({ "entities.id": { $in: ids }, ...(filter || {}) }) .find<User>({ "entities.id": { $in: ids }, ...(filter || {}) }, projection)
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
} }