Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useListSearch } from "@/hooks/useListSearch"
|
import { useListSearch } from "@/hooks/useListSearch"
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table"
|
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { BsArrowDown, BsArrowUp } from "react-icons/bs"
|
import { BsArrowDown, BsArrowUp } from "react-icons/bs"
|
||||||
import Button from "../Low/Button"
|
import Button from "../Low/Button"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {ComponentProps, useEffect, useState} from "react";
|
import { ComponentProps, useEffect, useState } from "react";
|
||||||
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
import ReactSelect, { GroupBase, StylesConfig } from "react-select";
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,14 +9,23 @@ interface Props {
|
|||||||
options: Option[];
|
options: Option[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange: (value: Option | null) => void;
|
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, label, className}: Props) {
|
interface MultiProps {
|
||||||
|
isMulti: true
|
||||||
|
onChange: (value: Option[] | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleProps {
|
||||||
|
isMulti?: false
|
||||||
|
onChange: (value: Option | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Select({ value, isMulti, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, label, className }: Props & (MultiProps | SingleProps)) {
|
||||||
const [target, setTarget] = useState<HTMLElement>();
|
const [target, setTarget] = useState<HTMLElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,6 +36,7 @@ export default function Select({value, defaultValue, options, placeholder, disab
|
|||||||
<div className="w-full flex flex-col gap-3">
|
<div className="w-full flex flex-col gap-3">
|
||||||
{label && <label className="font-normal text-base text-mti-gray-dim">{label}</label>}
|
{label && <label className="font-normal text-base text-mti-gray-dim">{label}</label>}
|
||||||
<ReactSelect
|
<ReactSelect
|
||||||
|
isMulti={isMulti}
|
||||||
className={
|
className={
|
||||||
styles
|
styles
|
||||||
? undefined
|
? undefined
|
||||||
@@ -44,7 +54,7 @@ export default function Select({value, defaultValue, options, placeholder, disab
|
|||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
styles={
|
styles={
|
||||||
styles || {
|
styles || {
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
|
|||||||
@@ -260,9 +260,13 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="w-full flex flex-col gap-1">
|
||||||
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
|
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
|
||||||
{!!assignment &&
|
{aggregatedLevels.map(({ module, level }) =>
|
||||||
(assignment.released || assignment.released === undefined) &&
|
<ModuleBadge
|
||||||
aggregatedLevels.map(({ module, level }) => <ModuleBadge key={module} module={module} level={level} />)}
|
key={module}
|
||||||
|
module={module}
|
||||||
|
level={(!!assignment && (assignment.released || assignment.released === undefined)) || !assignment ? level : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assignment && (
|
{assignment && (
|
||||||
|
|||||||
26
src/components/PracticeModal.tsx
Normal file
26
src/components/PracticeModal.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PracticeModal({ open }: Props) {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(open || false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Practice Questions" isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
||||||
|
<div className="py-4 flex flex-col gap-4 items-center">
|
||||||
|
<span className="w-full">
|
||||||
|
To acquaint yourself with the question types in this section, please respond to the practice questions provided.
|
||||||
|
<br />
|
||||||
|
<b>Do note that these questions are for practice purposes only and are not graded.</b>
|
||||||
|
<br />
|
||||||
|
You may choose to skip them if you prefer.
|
||||||
|
</span>
|
||||||
|
<Button onClick={() => setIsOpen(false)} className="w-full max-w-[200px]">Understood</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -171,7 +171,8 @@ export default function Sidebar({
|
|||||||
badge={totalAssignedTickets}
|
badge={totalAssignedTickets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(entitiesAllowGeneration.length > 0 || isAdmin) && (
|
{checkAccess(user, ["admin", "developer", "teacher", 'corporate', 'mastercorporate'])
|
||||||
|
&& (entitiesAllowGeneration.length > 0 || isAdmin) && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCloudFill}
|
Icon={BsCloudFill}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import Select from "react-select";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import { CURRENCIES } from "@/resources/paypal";
|
import { CURRENCIES } from "@/resources/paypal";
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
@@ -119,7 +118,6 @@ const UserCard = ({
|
|||||||
);
|
);
|
||||||
const { data: stats } = useFilterRecordsByUser<Stat[]>(user.id);
|
const { data: stats } = useFilterRecordsByUser<Stat[]>(user.id);
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const { codes } = useCodes(user.id);
|
|
||||||
const { permissions } = usePermissions(loggedInUser.id);
|
const { permissions } = usePermissions(loggedInUser.id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import RenderAudioInstructionsPlayer from "./components/RenderAudioInstructionsP
|
|||||||
import RenderAudioPlayer from "./components/RenderAudioPlayer";
|
import RenderAudioPlayer from "./components/RenderAudioPlayer";
|
||||||
import SectionNavbar from "./Navigation/SectionNavbar";
|
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||||
import ProgressButtons from "./components/ProgressButtons";
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
import PracticeModal from "@/components/PracticeModal";
|
||||||
|
|
||||||
|
|
||||||
const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
@@ -83,8 +84,11 @@ const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = f
|
|||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const hasPractice = exercises.some(e => e.isPractice)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<PracticeModal open={hasPractice} />
|
||||||
{formattedExercises.map(e => showSolutions
|
{formattedExercises.map(e => showSolutions
|
||||||
? renderSolution(e)
|
? renderSolution(e)
|
||||||
: (!startNow && !showPartDivider && !isBetweenParts && !showSolutions) && renderExercise(e, exam.id, registerSolution, preview))}
|
: (!startNow && !showPartDivider && !isBetweenParts && !showSolutions) && renderExercise(e, exam.id, registerSolution, preview))}
|
||||||
@@ -93,8 +97,6 @@ const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = f
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [partIndex, startNow, showPartDivider, isBetweenParts, showSolutions]);
|
}, [partIndex, startNow, showPartDivider, isBetweenParts, showSolutions]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
setShowBlankModal(false);
|
setShowBlankModal(false);
|
||||||
@@ -132,7 +134,7 @@ const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = f
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
/>, [partIndex, assignment, timesListened, setShowTextModal, setTimesListened])
|
/>, [partIndex, assignment, timesListened, setShowTextModal, setTimesListened])
|
||||||
|
|
||||||
const memoizedInstructions = useMemo(()=>
|
const memoizedInstructions = useMemo(() =>
|
||||||
<RenderAudioInstructionsPlayer />
|
<RenderAudioInstructionsPlayer />
|
||||||
, [])
|
, [])
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import { countExercises } from "@/utils/moduleUtils";
|
|||||||
import PartDivider from "./Navigation/SectionDivider";
|
import PartDivider from "./Navigation/SectionDivider";
|
||||||
import ReadingPassage from "./components/ReadingPassage";
|
import ReadingPassage from "./components/ReadingPassage";
|
||||||
//import ReadingPassageModal from "./components/ReadingPassageModal";
|
//import ReadingPassageModal from "./components/ReadingPassageModal";
|
||||||
import {calculateExerciseIndex} from "./utils/calculateExerciseIndex";
|
import { calculateExerciseIndex } from "./utils/calculateExerciseIndex";
|
||||||
import useExamNavigation from "./Navigation/useExamNavigation";
|
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||||
import { ExamProps } from "./types";
|
import { ExamProps } from "./types";
|
||||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import useExamTimer from "@/hooks/useExamTimer";
|
import useExamTimer from "@/hooks/useExamTimer";
|
||||||
import SectionNavbar from "./Navigation/SectionNavbar";
|
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||||
import ProgressButtons from "./components/ProgressButtons";
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
import PracticeModal from "@/components/PracticeModal";
|
||||||
|
|
||||||
const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
@@ -50,6 +51,13 @@ const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false
|
|||||||
startNow
|
startNow
|
||||||
} = useExamNavigation({ exam, module: "reading", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: showSolutions });
|
} = useExamNavigation({ exam, module: "reading", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: showSolutions });
|
||||||
|
|
||||||
|
const hasPractice = useMemo(() => {
|
||||||
|
if (partIndex > -1 && partIndex < exam.parts.length) {
|
||||||
|
return exam.parts[partIndex].exercises.some(e => e.isPractice)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [partIndex, exam.parts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (finalizeModule || timeIsUp) {
|
if (finalizeModule || timeIsUp) {
|
||||||
updateTimers();
|
updateTimers();
|
||||||
@@ -146,6 +154,7 @@ const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false
|
|||||||
/>
|
/>
|
||||||
</div> : (
|
</div> : (
|
||||||
<>
|
<>
|
||||||
|
<PracticeModal key={partIndex} open={hasPractice} />
|
||||||
<div className="flex flex-col h-full w-full gap-8">
|
<div className="flex flex-col h-full w-full gap-8">
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
{/*<ReadingPassageModal text={exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />*/}
|
{/*<ReadingPassageModal text={exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />*/}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import useExamNavigation from "./Navigation/useExamNavigation";
|
|||||||
import ProgressButtons from "./components/ProgressButtons";
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
|
import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
|
||||||
import SectionNavbar from "./Navigation/SectionNavbar";
|
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||||
|
import PracticeModal from "@/components/PracticeModal";
|
||||||
|
|
||||||
|
|
||||||
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
@@ -33,6 +34,7 @@ const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = fal
|
|||||||
const { finalizeModule, timeIsUp } = flags;
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
|
|
||||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
|
const hasPractice = useMemo(() => exam.exercises.some(e => e.isPractice), [exam.exercises])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
nextExercise, previousExercise,
|
nextExercise, previousExercise,
|
||||||
@@ -110,6 +112,7 @@ const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = fal
|
|||||||
onNext={handlePartDividerClick}
|
onNext={handlePartDividerClick}
|
||||||
/> : (
|
/> : (
|
||||||
<>
|
<>
|
||||||
|
<PracticeModal open={hasPractice} />
|
||||||
{exam.exercises.length > 1 && <SectionNavbar
|
{exam.exercises.length > 1 && <SectionNavbar
|
||||||
module="speaking"
|
module="speaking"
|
||||||
sectionLabel="Part"
|
sectionLabel="Part"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import useExamTimer from "@/hooks/useExamTimer";
|
|||||||
import useExamNavigation from "./Navigation/useExamNavigation";
|
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||||
import SectionNavbar from "./Navigation/SectionNavbar";
|
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||||
import ProgressButtons from "./components/ProgressButtons";
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
import PracticeModal from "@/components/PracticeModal";
|
||||||
|
|
||||||
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
@@ -27,6 +28,7 @@ const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false
|
|||||||
} = !preview ? examState : persistentExamState;
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
|
const hasPractice = useMemo(() => exam.exercises.some(e => e.isPractice), [exam.exercises])
|
||||||
|
|
||||||
const { finalizeModule, timeIsUp } = flags;
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
const { nextDisabled } = navigation;
|
const { nextDisabled } = navigation;
|
||||||
@@ -80,7 +82,7 @@ const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false
|
|||||||
|
|
||||||
const progressButtons = useMemo(() =>
|
const progressButtons = useMemo(() =>
|
||||||
// Do not remove the ()=> in handle next
|
// Do not remove the ()=> in handle next
|
||||||
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} nextDisabled={nextDisabled}/>
|
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} nextDisabled={nextDisabled} />
|
||||||
, [nextExercise, previousExercise, nextDisabled]);
|
, [nextExercise, previousExercise, nextDisabled]);
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false
|
|||||||
onNext={handlePartDividerClick}
|
onNext={handlePartDividerClick}
|
||||||
/> : (
|
/> : (
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
|
<PracticeModal open={hasPractice} />
|
||||||
{exam.exercises.length > 1 && <SectionNavbar
|
{exam.exercises.length > 1 && <SectionNavbar
|
||||||
module="writing"
|
module="writing"
|
||||||
sectionLabel="Part"
|
sectionLabel="Part"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {Code, Group, User} from "@/interfaces/user";
|
import { Code, Group, User } from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function useCodes(creator?: string) {
|
export default function useCodes(entity?: string) {
|
||||||
const [codes, setCodes] = useState<Code[]>([]);
|
const [codes, setCodes] = useState<Code[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
@@ -10,12 +10,12 @@ export default function useCodes(creator?: string) {
|
|||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<Code[]>(`/api/code${creator ? `?creator=${creator}` : ""}`)
|
.get<Code[]>(`/api/code${entity ? `?entity=${entity}` : ""}`)
|
||||||
.then((response) => setCodes(response.data))
|
.then((response) => setCodes(response.data))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, [creator]);
|
useEffect(getData, [entity]);
|
||||||
|
|
||||||
return {codes, isLoading, isError, reload: getData};
|
return { codes, isLoading, isError, reload: getData };
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/hooks/useEntitiesCodes.tsx
Normal file
25
src/hooks/useEntitiesCodes.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Code, Group, User } from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useEntitiesCodes(entities?: string[]) {
|
||||||
|
const [codes, setCodes] = useState<Code[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (entities)
|
||||||
|
entities.forEach(e => params.append("entities", e))
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get<Code[]>(`/api/code/entities${entities ? `?${params.toString()}` : ""}`)
|
||||||
|
.then((response) => setCodes(response.data))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, [entities]);
|
||||||
|
|
||||||
|
return { codes, isLoading, isError, reload: getData };
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {ExamState} from "@/stores/exam/types";
|
import { ExamState } from "@/stores/exam/types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export type Session = ExamState & {user: string; id: string; date: string};
|
export type Session = ExamState & { user: string; id: string; date: string };
|
||||||
|
|
||||||
export default function useSessions(user?: string) {
|
export default function useSessions(user?: string) {
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
@@ -19,5 +19,5 @@ export default function useSessions(user?: string) {
|
|||||||
|
|
||||||
useEffect(getData, [user]);
|
useEffect(getData, [user]);
|
||||||
|
|
||||||
return {sessions, isLoading, isError, reload: getData};
|
return { sessions, isLoading, isError, reload: getData };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { RolePermission } from "@/resources/entityPermissions";
|
|||||||
export interface Entity {
|
export interface Entity {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
licenses: number
|
licenses: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export interface GroupWithUsers extends Omit<Group, "participants" | "admin"> {
|
|||||||
export interface Code {
|
export interface Code {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
entity: string
|
||||||
creator: string;
|
creator: string;
|
||||||
expiryDate: Date;
|
expiryDate: Date;
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
|
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
@@ -59,11 +61,12 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
|
entities: EntityWithRoles[]
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchCodeGenerator({user, users, permissions, onFinish}: Props) {
|
export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) {
|
||||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||||
@@ -71,8 +74,9 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}:
|
|||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
||||||
|
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
@@ -139,7 +143,7 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}:
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
|
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id })))
|
||||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
@@ -155,19 +159,20 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}:
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {
|
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||||
type,
|
type,
|
||||||
codes,
|
codes,
|
||||||
infos: informations,
|
infos: informations.map((info, index) => ({ ...info, code: codes[index] })),
|
||||||
expiryDate,
|
expiryDate,
|
||||||
|
entity
|
||||||
})
|
})
|
||||||
.then(({data, status}) => {
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||||
type,
|
type,
|
||||||
)} codes and they have been notified by e-mail!`,
|
)} codes and they have been notified by e-mail!`,
|
||||||
{toastId: "success"},
|
{ toastId: "success" },
|
||||||
);
|
);
|
||||||
|
|
||||||
onFinish();
|
onFinish();
|
||||||
@@ -175,12 +180,12 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({response: {status, data}}) => {
|
.catch(({ response: { status, data } }) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +263,15 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}:
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||||
|
<Select
|
||||||
|
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||||
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
||||||
{user && (
|
{user && (
|
||||||
<select
|
<select
|
||||||
@@ -266,7 +280,7 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}:
|
|||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
.filter((x) => {
|
.filter((x) => {
|
||||||
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||||
})
|
})
|
||||||
.map((type) => (
|
.map((type) => (
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
@@ -51,16 +54,19 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
|
entities: EntityWithRoles[]
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CodeGenerator({user, permissions, onFinish}: Props) {
|
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
|
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
@@ -71,8 +77,8 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) {
|
|||||||
const code = uid.randomUUID(6);
|
const code = uid.randomUUID(6);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post("/api/code", {type, codes: [code], expiryDate})
|
.post("/api/code", { type, codes: [code], expiryDate, entity })
|
||||||
.then(({data, status}) => {
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||||
toastId: "success",
|
toastId: "success",
|
||||||
@@ -82,12 +88,12 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({response: {status, data}}) => {
|
.catch(({ response: { status, data } }) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +106,25 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
||||||
{user && (
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||||
|
<Select
|
||||||
|
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||||
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
.filter((x) => {
|
.filter((x) => {
|
||||||
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||||
})
|
})
|
||||||
.map((type) => (
|
.map((type) => (
|
||||||
@@ -116,8 +133,9 @@ export default function CodeGenerator({user, permissions, onFinish}: Props) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
</div>
|
||||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
|
||||||
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
|
import Separator from "@/components/Low/Separator";
|
||||||
import { Grading, Step } from "@/interfaces";
|
import { Grading, Step } from "@/interfaces";
|
||||||
import { Entity } from "@/interfaces/entity";
|
import { Entity } from "@/interfaces/entity";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading";
|
import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading";
|
||||||
|
import { mapBy } from "@/utils";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Divider } from "primereact/divider";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BsPlusCircle, BsTrash } from "react-icons/bs";
|
import { BsPlusCircle, BsTrash } from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
@@ -36,6 +39,7 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
|
|||||||
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined)
|
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined)
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [steps, setSteps] = useState<Step[]>([]);
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
|
const [otherEntities, setOtherEntities] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (entity) {
|
if (entity) {
|
||||||
@@ -63,6 +67,27 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyToOtherEntities = () => {
|
||||||
|
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold.");
|
||||||
|
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
||||||
|
if (
|
||||||
|
steps.reduce((acc, curr) => {
|
||||||
|
return acc - (curr.max - curr.min + 1);
|
||||||
|
}, 100) > 0
|
||||||
|
)
|
||||||
|
return toast.error("There seems to be an open interval in your steps.");
|
||||||
|
|
||||||
|
if (otherEntities.length === 0) return toast.error("Select at least one entity")
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.post("/api/grading/multiple", { user: user.id, entities: otherEntities, steps })
|
||||||
|
.then(() => toast.success("Your grading system has been saved!"))
|
||||||
|
.then(mutate)
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Grading System</label>
|
<label className="font-normal text-base text-mti-gray-dim">Grading System</label>
|
||||||
@@ -76,6 +101,22 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{entities.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Apply this grading system to other entities</label>
|
||||||
|
<Select
|
||||||
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
|
onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))}
|
||||||
|
isMulti
|
||||||
|
/>
|
||||||
|
<Button onClick={applyToOtherEntities} isLoading={isLoading} disabled={isLoading || otherEntities.length === 0} variant="outline">
|
||||||
|
Apply to {otherEntities.length} other entities
|
||||||
|
</Button>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label>
|
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label>
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
||||||
|
|||||||
@@ -1,81 +1,47 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import useUser from "@/hooks/useUser";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Code, User } from "@/interfaces/user";
|
import { Code, User } from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { BsTrash } from "react-icons/bs";
|
import { BsTrash } from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import clsx from "clsx";
|
import { isAdmin } from "@/utils/users";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { findBy, mapBy } from "@/utils";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import useEntitiesCodes from "@/hooks/useEntitiesCodes";
|
||||||
|
import Table from "@/components/High/Table";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Code>();
|
type TableData = Code & { entity?: EntityWithRoles, creator?: User }
|
||||||
|
const columnHelper = createColumnHelper<TableData>();
|
||||||
|
|
||||||
const CreatorCell = ({ id, users }: { id: string; users: User[] }) => {
|
export default function CodeList({ user, entities, canDeleteCodes }
|
||||||
const [creatorUser, setCreatorUser] = useState<User>();
|
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) {
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCreatorUser(users.find((x) => x.id === id));
|
|
||||||
}, [id, users]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{(creatorUser?.name || "N/A") || "N/A"}{" "}
|
|
||||||
{creatorUser && `(${USER_TYPE_LABELS[creatorUser?.type]})`}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CodeList({ user, canDeleteCodes }: { user: User, canDeleteCodes?: boolean }) {
|
|
||||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(user?.type === "corporate" ? user : undefined);
|
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities])
|
||||||
const [filterAvailability, setFilterAvailability] = useState<"in-use" | "unused">();
|
|
||||||
|
|
||||||
const { permissions } = usePermissions(user?.id || "");
|
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const { codes, reload } = useCodes(user?.type === "corporate" ? user?.id : undefined);
|
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs)
|
||||||
|
|
||||||
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
|
const data: TableData[] = useMemo(() => codes.map((code) => ({
|
||||||
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
|
...code,
|
||||||
const filteredCodes = useMemo(() => {
|
entity: findBy(entities, 'id', code.entity),
|
||||||
return codes.filter((x) => {
|
creator: findBy(users, 'id', code.creator)
|
||||||
// TODO: if the expiry date is missing, it does not make sense to filter by date
|
})) as TableData[], [codes, entities, users])
|
||||||
// so we need to find a way to handle this edge case
|
|
||||||
if (startDate && endDate && x.expiryDate) {
|
|
||||||
const date = moment(x.expiryDate);
|
|
||||||
if (date.isBefore(startDate) || date.isAfter(endDate)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
|
|
||||||
if (filterAvailability) {
|
|
||||||
if (filterAvailability === "in-use" && !x.userId) return false;
|
|
||||||
if (filterAvailability === "unused" && x.userId) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
|
|
||||||
|
|
||||||
const toggleCode = (id: string) => {
|
const toggleCode = (id: string) => {
|
||||||
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAllCodes = (checked: boolean) => {
|
// const toggleAllCodes = (checked: boolean) => {
|
||||||
if (checked) return setSelectedCodes(filteredCodes.filter((x) => !x.userId).map((x) => x.code));
|
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
|
||||||
|
|
||||||
return setSelectedCodes([]);
|
// return setSelectedCodes([]);
|
||||||
};
|
// };
|
||||||
|
|
||||||
const deleteCodes = async (codes: string[]) => {
|
const deleteCodes = async (codes: string[]) => {
|
||||||
if (!canDeleteCodes) return
|
if (!canDeleteCodes) return
|
||||||
@@ -132,16 +98,8 @@ export default function CodeList({ user, canDeleteCodes }: { user: User, canDele
|
|||||||
const defaultColumns = [
|
const defaultColumns = [
|
||||||
columnHelper.accessor("code", {
|
columnHelper.accessor("code", {
|
||||||
id: "codeCheckbox",
|
id: "codeCheckbox",
|
||||||
header: () => (
|
enableSorting: false,
|
||||||
<Checkbox
|
header: () => (""),
|
||||||
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
|
|
||||||
isChecked={
|
|
||||||
selectedCodes.length === filteredCodes.filter((x) => !x.userId).length && filteredCodes.filter((x) => !x.userId).length > 0
|
|
||||||
}
|
|
||||||
onChange={(checked) => toggleAllCodes(checked)}>
|
|
||||||
{""}
|
|
||||||
</Checkbox>
|
|
||||||
),
|
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!info.row.original.userId ? (
|
!info.row.original.userId ? (
|
||||||
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
|
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
|
||||||
@@ -158,12 +116,16 @@ export default function CodeList({ user, canDeleteCodes }: { user: User, canDele
|
|||||||
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
|
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("email", {
|
columnHelper.accessor("email", {
|
||||||
header: "Invited E-mail",
|
header: "E-mail",
|
||||||
cell: (info) => info.getValue() || "N/A",
|
cell: (info) => info.getValue() || "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("creator", {
|
columnHelper.accessor("creator", {
|
||||||
header: "Creator",
|
header: "Creator",
|
||||||
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
|
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("entity", {
|
||||||
|
header: "Entity",
|
||||||
|
cell: (info) => info.getValue()?.label || "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("userId", {
|
columnHelper.accessor("userId", {
|
||||||
header: "Availability",
|
header: "Availability",
|
||||||
@@ -195,71 +157,11 @@ export default function CodeList({ user, canDeleteCodes }: { user: User, canDele
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: filteredCodes,
|
|
||||||
columns: defaultColumns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between pb-4 pt-1">
|
<div className="flex items-center justify-between pb-4 pt-1">
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Select
|
|
||||||
className="!w-96 !py-1"
|
|
||||||
disabled={user?.type === "corporate"}
|
|
||||||
isClearable
|
|
||||||
placeholder="Corporate"
|
|
||||||
value={
|
|
||||||
filteredCorporate
|
|
||||||
? {
|
|
||||||
label: `${filteredCorporate.name} (${USER_TYPE_LABELS[filteredCorporate?.type]})`,
|
|
||||||
value: filteredCorporate.id,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
options={users
|
|
||||||
.filter((x) => ["admin", "developer", "corporate"].includes(x.type))
|
|
||||||
.map((x) => ({
|
|
||||||
label: `${x.name} (${USER_TYPE_LABELS[x.type]})`,
|
|
||||||
value: x.id,
|
|
||||||
user: x,
|
|
||||||
}))}
|
|
||||||
onChange={(value) => setFilteredCorporate(value ? users.find((x) => x.id === value?.value) : undefined)}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
className="!w-96 !py-1"
|
|
||||||
placeholder="Availability"
|
|
||||||
isClearable
|
|
||||||
options={[
|
|
||||||
{ label: "In Use", value: "in-use" },
|
|
||||||
{ label: "Unused", value: "unused" },
|
|
||||||
]}
|
|
||||||
onChange={(value) => setFilterAvailability(value ? (value.value as typeof filterAvailability) : undefined)}
|
|
||||||
/>
|
|
||||||
<ReactDatePicker
|
|
||||||
dateFormat="dd/MM/yyyy"
|
|
||||||
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
|
||||||
selected={startDate}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
selectsRange
|
|
||||||
showMonthDropdown
|
|
||||||
filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
|
|
||||||
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
|
||||||
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
|
||||||
if (finalDate) {
|
|
||||||
// basicly selecting a final day works as if I'm selecting the first
|
|
||||||
// minute of that day. this way it covers the whole day
|
|
||||||
setEndDate(moment(finalDate).endOf("day").toDate());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEndDate(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{canDeleteCodes && (
|
{canDeleteCodes && (
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center w-full justify-end">
|
||||||
<span>{selectedCodes.length} code(s) selected</span>
|
<span>{selectedCodes.length} code(s) selected</span>
|
||||||
<Button
|
<Button
|
||||||
disabled={selectedCodes.length === 0}
|
disabled={selectedCodes.length === 0}
|
||||||
@@ -272,30 +174,11 @@ export default function CodeList({ user, canDeleteCodes }: { user: User, canDele
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<Table<TableData>
|
||||||
<thead>
|
data={data}
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
columns={defaultColumns}
|
||||||
<tr key={headerGroup.id}>
|
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]}
|
||||||
{headerGroup.headers.map((header) => (
|
/>
|
||||||
<th className="p-4 text-left" key={header.id}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody className="px-2">
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,30 @@ import Checkbox from "@/components/Low/Checkbox";
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import useDiscounts from "@/hooks/useDiscounts";
|
import useDiscounts from "@/hooks/useDiscounts";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Discount} from "@/interfaces/paypal";
|
import { Discount } from "@/interfaces/paypal";
|
||||||
import {Code, User} from "@/interfaces/user";
|
import { Code, User } from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {BsPencil, BsTrash} from "react-icons/bs";
|
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Discount>();
|
const columnHelper = createColumnHelper<Discount>();
|
||||||
|
|
||||||
const DiscountCreator = ({discount, onClose}: {discount?: Discount; onClose: () => void}) => {
|
const DiscountCreator = ({ discount, onClose }: { discount?: Discount; onClose: () => void }) => {
|
||||||
const [percentage, setPercentage] = useState(discount?.percentage);
|
const [percentage, setPercentage] = useState(discount?.percentage);
|
||||||
const [domain, setDomain] = useState(discount?.domain);
|
const [domain, setDomain] = useState(discount?.domain);
|
||||||
const [validUntil, setValidUntil] = useState(discount?.validUntil);
|
const [validUntil, setValidUntil] = useState(discount?.validUntil);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const body = {percentage, domain, validUntil: validUntil?.toISOString() || undefined};
|
const body = { percentage, domain, validUntil: validUntil?.toISOString() || undefined };
|
||||||
|
|
||||||
if (discount) {
|
if (discount) {
|
||||||
return axios
|
return axios
|
||||||
@@ -112,7 +111,7 @@ const DiscountCreator = ({discount, onClose}: {discount?: Discount; onClose: ()
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DiscountList({user}: {user: User}) {
|
export default function DiscountList({ user }: { user: User }) {
|
||||||
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
|
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
|
||||||
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -120,8 +119,8 @@ export default function DiscountList({user}: {user: User}) {
|
|||||||
|
|
||||||
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
|
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
const {discounts, reload} = useDiscounts();
|
const { discounts, reload } = useDiscounts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilteredDiscounts(discounts);
|
setFilteredDiscounts(discounts);
|
||||||
@@ -220,7 +219,7 @@ export default function DiscountList({user}: {user: User}) {
|
|||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({row}: {row: {original: Discount}}) => {
|
cell: ({ row }: { row: { original: Discount } }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function Lists({ user, entities = [], permissions }: Props) {
|
|||||||
Exam List
|
Exam List
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate"]) && entitiesViewCodes.length > 0 && (
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && entitiesViewCodes.length > 0 && (
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
@@ -106,7 +106,7 @@ export default function Lists({ user, entities = [], permissions }: Props) {
|
|||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && entitiesViewCodes.length > 0 && (
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && entitiesViewCodes.length > 0 && (
|
||||||
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
<CodeList user={user} canDeleteCodes={entitiesDeleteCodes.length > 0} />
|
<CodeList user={user} entities={entitiesViewCodes} canDeleteCodes={entitiesDeleteCodes.length > 0} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin"]) && (
|
{checkAccess(user, ["developer", "admin"]) && (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import { ExamProps } from "@/exams/types";
|
import { ExamProps } from "@/exams/types";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
||||||
|
import PracticeModal from "@/components/PracticeModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: "exams" | "exercises";
|
page: "exams" | "exercises";
|
||||||
@@ -128,7 +129,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
if (exercise.type === "writing")
|
if (exercise.type === "writing")
|
||||||
await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url);
|
await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url);
|
||||||
|
|
||||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking"){
|
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") {
|
||||||
await evaluateSpeakingAnswer(
|
await evaluateSpeakingAnswer(
|
||||||
user.id,
|
user.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -144,7 +145,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
}
|
}
|
||||||
}, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]);
|
}, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]);
|
||||||
|
|
||||||
useEvaluationPolling({pendingExercises, setPendingExercises});
|
useEvaluationPolling({ pendingExercises, setPendingExercises });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (flags.finalizeExam && moduleIndex !== -1) {
|
if (flags.finalizeExam && moduleIndex !== -1) {
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { ToastContainer } from "react-toastify";
|
|||||||
import useDiscounts from "@/hooks/useDiscounts";
|
import useDiscounts from "@/hooks/useDiscounts";
|
||||||
import PaymobPayment from "@/components/PaymobPayment";
|
import PaymobPayment from "@/components/PaymobPayment";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
entities: EntityWithRoles[]
|
||||||
hasExpired?: boolean;
|
hasExpired?: boolean;
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {FormEvent, useEffect, useState} from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {Divider} from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export function getServerSideProps({
|
export function getServerSideProps({
|
||||||
query,
|
query,
|
||||||
@@ -35,12 +35,12 @@ export function getServerSideProps({
|
|||||||
props: {
|
props: {
|
||||||
code: query.oobCode,
|
code: query.oobCode,
|
||||||
mode: query.mode,
|
mode: query.mode,
|
||||||
...(query.continueUrl ? {continueUrl: query.continueUrl} : {}),
|
...(query.continueUrl ? { continueUrl: query.continueUrl } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Reset({code, mode, continueUrl}: {code: string; mode: string; continueUrl?: string}) {
|
export default function Reset({ code, mode, continueUrl }: { code: string; mode: string; continueUrl?: string }) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export default function Reset({code, mode, continueUrl}: {code: string; mode: st
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "signIn") {
|
if (mode === "signIn") {
|
||||||
axios
|
axios
|
||||||
.post<{ok: boolean}>("/api/reset/verify", {
|
.post<{ ok: boolean }>("/api/reset/verify", {
|
||||||
email: continueUrl?.replace("https://platform.encoach.com/", "").replace("https://staging.encoach.com/", ""),
|
email: continueUrl?.replace("https://platform.encoach.com/", "").replace("https://staging.encoach.com/", ""),
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -64,7 +64,7 @@ export default function Reset({code, mode, continueUrl}: {code: string; mode: st
|
|||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}, 1000);
|
}, 500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export default function Reset({code, mode, continueUrl}: {code: string; mode: st
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post<{ok: boolean}>("/api/reset/confirm", {code, password})
|
.post<{ ok: boolean }>("/api/reset/confirm", { code, password })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
toast.success("Your password has been reset!", {
|
toast.success("Your password has been reset!", {
|
||||||
@@ -98,10 +98,10 @@ export default function Reset({code, mode, continueUrl}: {code: string; mode: st
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", { toastId: "reset-error" });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", { toastId: "reset-error" });
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|||||||
36
src/pages/api/code/entities.ts
Normal file
36
src/pages/api/code/entities.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Code, Group, Type } from "@/interfaces/user";
|
||||||
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
|
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { doesEntityAllow } from "@/utils/permissions";
|
||||||
|
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
|
||||||
|
import { findBy } from "@/utils";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user)
|
||||||
|
return res.status(401).json({ ok: false, reason: "You must be logged in!" })
|
||||||
|
|
||||||
|
const { entities } = req.query as { entities?: string[] };
|
||||||
|
if (entities)
|
||||||
|
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: entities } }).toArray());
|
||||||
|
|
||||||
|
return res.status(200).json(await db.collection("codes").find<Code>({}).toArray());
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@ import { sessionOptions } from "@/lib/session";
|
|||||||
import { Code, Group, Type } from "@/interfaces/user";
|
import { Code, Group, Type } from "@/interfaces/user";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { doesEntityAllow } from "@/utils/permissions";
|
||||||
|
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
|
||||||
|
import { findBy } from "@/utils";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -25,68 +31,29 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { creator } = req.query as { creator?: string };
|
const { entity } = req.query as { entity?: string };
|
||||||
const snapshot = await db.collection("codes").find(creator ? { creator: creator } : {}).toArray();
|
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
|
||||||
|
|
||||||
res.status(200).json(snapshot);
|
res.status(200).json(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
const generateAndSendCode = async (
|
||||||
if (!req.session.user) {
|
code: string,
|
||||||
res.status(401).json({ ok: false, reason: "You must be logged in to generate a code!" });
|
type: Type,
|
||||||
return;
|
creator: string,
|
||||||
|
expiryDate: null | Date,
|
||||||
|
entity?: string,
|
||||||
|
info?: {
|
||||||
|
email: string; name: string; passport_id?: string
|
||||||
|
}) => {
|
||||||
|
if (!info) {
|
||||||
|
await db.collection("codes").insertOne({
|
||||||
|
code, type, creator, expiryDate, entity, creationDate: new Date().toISOString()
|
||||||
|
})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, codes, infos, expiryDate } = req.body as {
|
const previousCode = await db.collection("codes").findOne<Code>({ email: info.email, entity })
|
||||||
type: Type;
|
|
||||||
codes: string[];
|
|
||||||
infos?: { email: string; name: string; passport_id?: string }[];
|
|
||||||
expiryDate: null | Date;
|
|
||||||
};
|
|
||||||
const permission = PERMISSIONS.generateCode[type];
|
|
||||||
|
|
||||||
if (!permission.includes(req.session.user.type)) {
|
|
||||||
res.status(403).json({
|
|
||||||
ok: false,
|
|
||||||
reason: "Your account type does not have permissions to generate a code for that type of user!",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userCodes = await db.collection("codes").find<Code>({ creator: req.session.user.id }).toArray()
|
|
||||||
const creatorGroupsSnapshot = await db.collection("groups").find<Group>({ admin: req.session.user.id }).toArray()
|
|
||||||
|
|
||||||
const creatorGroups = creatorGroupsSnapshot.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
|
|
||||||
const usersInGroups = creatorGroups.flatMap((x) => x.participants);
|
|
||||||
|
|
||||||
|
|
||||||
if (req.session.user.type === "corporate") {
|
|
||||||
const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length;
|
|
||||||
const allowedCodes = 0;
|
|
||||||
|
|
||||||
if (totalCodes > allowedCodes) {
|
|
||||||
res.status(403).json({
|
|
||||||
ok: false,
|
|
||||||
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${allowedCodes - userCodes.length
|
|
||||||
} codes.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codePromises = codes.map(async (code, index) => {
|
|
||||||
const codeRef = await db.collection("codes").findOne<Code>({ id: code });
|
|
||||||
let codeInformation = {
|
|
||||||
type,
|
|
||||||
code,
|
|
||||||
creator: req.session.user!.id,
|
|
||||||
creationDate: new Date().toISOString(),
|
|
||||||
expiryDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (infos && infos.length > index) {
|
|
||||||
const { email, name, passport_id } = infos[index];
|
|
||||||
const previousCode = userCodes.find((x) => x.email === email) as Code;
|
|
||||||
|
|
||||||
const transport = prepareMailer();
|
const transport = prepareMailer();
|
||||||
const mailOptions = prepareMailOptions(
|
const mailOptions = prepareMailOptions(
|
||||||
@@ -95,47 +62,69 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
code: previousCode ? previousCode.code : code,
|
code: previousCode ? previousCode.code : code,
|
||||||
environment: process.env.ENVIRONMENT,
|
environment: process.env.ENVIRONMENT,
|
||||||
},
|
},
|
||||||
[email.toLowerCase().trim()],
|
[info.email.toLowerCase().trim()],
|
||||||
"EnCoach Registration",
|
"EnCoach Registration",
|
||||||
"main",
|
"main",
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.sendMail(mailOptions);
|
await transport.sendMail(mailOptions);
|
||||||
|
if (!previousCode) {
|
||||||
if (!previousCode && codeRef) {
|
await db.collection("codes").insertOne({
|
||||||
await db.collection("codes").updateOne(
|
code, type, creator, expiryDate, entity, name: info.name.trim(), email: info.email.trim().toLowerCase(),
|
||||||
{ id: codeRef.id },
|
...(info.passport_id ? { passport_id: info.passport_id.trim() } : {}),
|
||||||
{
|
creationDate: new Date().toISOString()
|
||||||
$set: {
|
})
|
||||||
id: codeRef.id,
|
|
||||||
...codeInformation,
|
|
||||||
email: email.trim().toLowerCase(),
|
|
||||||
name: name.trim(),
|
|
||||||
...(passport_id ? { passport_id: passport_id.trim() } : {}),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ upsert: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// upsert: true -> if it doesnt exist insert
|
|
||||||
await db.collection("codes").updateOne(
|
|
||||||
{ id: code },
|
|
||||||
{ $set: { id: code, ...codeInformation } },
|
|
||||||
{ upsert: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all(codePromises).then((results) => {
|
const countAvailableCodes = async (entity: EntityWithRoles) => {
|
||||||
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
|
const usedUp = await db.collection("codes").countDocuments({ entity: entity.id })
|
||||||
});
|
const total = entity.licenses
|
||||||
|
|
||||||
|
return total - usedUp
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return res.status(401).json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||||
|
|
||||||
|
const { type, codes, infos, expiryDate, entity } = req.body as {
|
||||||
|
type: Type;
|
||||||
|
codes: string[];
|
||||||
|
infos?: { email: string; name: string; passport_id?: string, code: string }[];
|
||||||
|
expiryDate: null | Date;
|
||||||
|
entity?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!entity && !isAdmin(user))
|
||||||
|
return res.status(403).json({ ok: false, reason: "You must be an admin to generate a code without an entity!" });
|
||||||
|
|
||||||
|
const entityObj = entity ? await getEntityWithRoles(entity) : undefined
|
||||||
|
const isAllowed = entityObj ? doesEntityAllow(user, entityObj, 'create_code') : true
|
||||||
|
if (!isAllowed) return res.status(403).json({ ok: false, reason: "You do not have permissions to generate a code!" });
|
||||||
|
|
||||||
|
if (entityObj) {
|
||||||
|
const availableCodes = await countAvailableCodes(entityObj)
|
||||||
|
if (availableCodes < codes.length)
|
||||||
|
return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
reason: `You only have ${availableCodes} codes available, while trying to create ${codes.length} codes`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const valid = []
|
||||||
|
for (const code of codes) {
|
||||||
|
const info = findBy(infos || [], 'code', code)
|
||||||
|
const isValid = await generateAndSendCode(code, type, user.id, expiryDate, entity, info)
|
||||||
|
valid.push(isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true, valid: valid.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
48
src/pages/api/grading/multiple.ts
Normal file
48
src/pages/api/grading/multiple.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { CorporateUser, Group } from "@/interfaces/user";
|
||||||
|
import { Discount, Package } from "@/interfaces/paypal";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { CEFR_STEPS } from "@/resources/grading";
|
||||||
|
import { getCorporateUser } from "@/resources/user";
|
||||||
|
import { getUserCorporate } from "@/utils/groups.be";
|
||||||
|
import { Grading, Step } from "@/interfaces";
|
||||||
|
import { getGroupsForUser } from "@/utils/groups.be";
|
||||||
|
import { uniq } from "lodash";
|
||||||
|
import { getSpecificUsers, getUser } from "@/utils/users.be";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") await post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkAccess(req.session.user, ["admin", "developer", "mastercorporate", "corporate"]))
|
||||||
|
return res.status(403).json({
|
||||||
|
ok: false,
|
||||||
|
reason: "You do not have permission to create a new grading system",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = req.body as {
|
||||||
|
entities: string[]
|
||||||
|
steps: Step[];
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.collection("grading").updateMany({ entity: { $in: body.entities } }, { $set: { steps: body.steps } }, { upsert: true });
|
||||||
|
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import {NextApiRequest, NextApiResponse} from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
|
||||||
import {app} from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {Code, CorporateInformation, DemographicInformation, Group, Type} from "@/interfaces/user";
|
import { Code, CorporateInformation, DemographicInformation, Group, Type } from "@/interfaces/user";
|
||||||
import {addUserToGroupOnCreation} from "@/utils/registration";
|
import { addUserToGroupOnCreation } from "@/utils/registration";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {v4} from "uuid";
|
import { v4 } from "uuid";
|
||||||
import client from "@/lib/mongodb";
|
import client from "@/lib/mongodb";
|
||||||
|
import { addUserToEntity, getEntityWithRoles } from "@/utils/entities.be";
|
||||||
|
import { findBy } from "@/utils";
|
||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
@@ -29,7 +31,7 @@ const DEFAULT_LEVELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function register(req: NextApiRequest, res: NextApiResponse) {
|
async function register(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {type} = req.body as {
|
const { type } = req.body as {
|
||||||
type: "individual" | "corporate";
|
type: "individual" | "corporate";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,19 +40,18 @@ async function register(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {email, passport_id, password, code} = req.body as {
|
const { email, passport_id, password, code } = req.body as {
|
||||||
email: string;
|
email: string;
|
||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
password: string;
|
password: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const codeDoc = await db.collection("codes").findOne<Code>({code});
|
const codeDoc = await db.collection("codes").findOne<Code>({ code });
|
||||||
|
|
||||||
|
if (code && code.length > 0 && !codeDoc)
|
||||||
|
return res.status(400).json({ error: "Invalid Code!" });
|
||||||
|
|
||||||
if (code && code.length > 0 && !!codeDoc) {
|
|
||||||
res.status(400).json({error: "Invalid Code!"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
@@ -69,34 +70,39 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
focus: "academic",
|
focus: "academic",
|
||||||
type: email.endsWith("@ecrop.dev") ? "developer" : codeDoc ? codeDoc.type : "student",
|
type: email.endsWith("@ecrop.dev") ? "developer" : codeDoc ? codeDoc.type : "student",
|
||||||
subscriptionExpirationDate: codeDoc ? codeDoc.expiryDate : moment().subtract(1, "days").toISOString(),
|
subscriptionExpirationDate: codeDoc ? codeDoc.expiryDate : moment().subtract(1, "days").toISOString(),
|
||||||
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
...(passport_id ? { demographicInformation: { passport_id } } : {}),
|
||||||
registrationDate: new Date().toISOString(),
|
registrationDate: new Date().toISOString(),
|
||||||
status: code ? "active" : "paymentDue",
|
status: code ? "active" : "paymentDue",
|
||||||
// apparently there's an issue with the verification email system
|
entities: [],
|
||||||
// therefore we will skip this requirement for now
|
isVerified: !!codeDoc,
|
||||||
isVerified: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.collection("users").insertOne(user);
|
await db.collection("users").insertOne(user);
|
||||||
|
|
||||||
if (!!codeDoc) {
|
if (!!codeDoc) {
|
||||||
await db.collection("codes").updateOne({code: codeDoc.code}, {$set: {userId}});
|
await db.collection("codes").updateOne({ code: codeDoc.code }, { $set: { userId } });
|
||||||
if (codeDoc.creator) await addUserToGroupOnCreation(userId, codeDoc.type, codeDoc.creator);
|
if (codeDoc.entity) {
|
||||||
|
const inviteEntity = await getEntityWithRoles(codeDoc.entity)
|
||||||
|
if (inviteEntity) {
|
||||||
|
const defaultRole = findBy(inviteEntity.roles, 'isDefault', true)!
|
||||||
|
await addUserToEntity(userId, codeDoc.entity, defaultRole.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.session.user = user;
|
req.session.user = user;
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({user});
|
res.status(200).json({ user });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
res.status(401).json({error});
|
res.status(401).json({ error });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {email, password} = req.body as {
|
const { email, password } = req.body as {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
corporateInformation: CorporateInformation;
|
corporateInformation: CorporateInformation;
|
||||||
@@ -155,10 +161,10 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
req.session.user = user;
|
req.session.user = user;
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({user});
|
res.status(200).json({ user });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
res.status(401).json({error});
|
res.status(401).json({ error });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import client from "@/lib/mongodb";
|
import client from "@/lib/mongodb";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {Session} from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
@@ -17,14 +17,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {user} = req.query as {user?: string};
|
const { user } = req.query as { user?: string };
|
||||||
|
|
||||||
const q = user ? {user: user} : {};
|
const q = user ? { user: user } : {};
|
||||||
const sessions = await db.collection("sessions").find<Session>(q).limit(10).toArray();
|
const sessions = await db.collection("sessions").find<Session>({
|
||||||
|
...q,
|
||||||
|
}).limit(12).toArray();
|
||||||
|
console.log(sessions)
|
||||||
|
|
||||||
res.status(200).json(
|
res.status(200).json(
|
||||||
sessions.filter((x) => {
|
sessions.filter((x) => {
|
||||||
@@ -37,12 +40,12 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = req.body;
|
const session = req.body;
|
||||||
|
|
||||||
await db.collection("sessions").updateOne({id: session.id}, {$set: session}, {upsert: true});
|
await db.collection("sessions").updateOne({ id: session.id }, { $set: session }, { upsert: true });
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,40 +2,40 @@
|
|||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import Tooltip from "@/components/Low/Tooltip";
|
import Tooltip from "@/components/Low/Tooltip";
|
||||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import usePagination from "@/hooks/usePagination";
|
import usePagination from "@/hooks/usePagination";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import {GroupWithUsers, User} from "@/interfaces/user";
|
import { GroupWithUsers, User } from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
|
import { getEntityWithRoles } from "@/utils/entities.be";
|
||||||
import {convertToUsers, getGroup} from "@/utils/groups.be";
|
import { convertToUsers, getGroup } from "@/utils/groups.be";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import {checkAccess, doesEntityAllow, findAllowedEntities, getTypesOfUser} from "@/utils/permissions";
|
import { doesEntityAllow } from "@/utils/permissions";
|
||||||
import {getUserName} from "@/utils/users";
|
import { getUserName, isAdmin } from "@/utils/users";
|
||||||
import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be";
|
import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {Divider} from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX} from "react-icons/bs";
|
import { BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX } from "react-icons/bs";
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/")
|
||||||
|
|
||||||
const {id} = params as {id: string};
|
const { id } = params as { id: string };
|
||||||
|
|
||||||
const group = await getGroup(id);
|
const group = await getGroup(id);
|
||||||
if (!group || !group.entity) return redirect("/classrooms")
|
if (!group || !group.entity) return redirect("/classrooms")
|
||||||
@@ -51,7 +51,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
|
|||||||
const groupWithUser = convertToUsers(group, users);
|
const groupWithUser = convertToUsers(group, users);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({user, group: groupWithUser, users: linkedUsers, entity}),
|
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ interface Props {
|
|||||||
entity: EntityWithRoles
|
entity: EntityWithRoles
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({user, group, users, entity}: Props) {
|
export default function Home({ user, group, users, entity }: Props) {
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
@@ -77,11 +77,11 @@ export default function Home({user, group, users, entity}: Props) {
|
|||||||
[users, group.participants, group.admin.id, user.id],
|
[users, group.participants, group.admin.id, user.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {rows, renderSearch} = useListSearch<User>(
|
const { rows, renderSearch } = useListSearch<User>(
|
||||||
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||||
isAdding ? nonParticipantUsers : group.participants,
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
);
|
);
|
||||||
const {items, renderMinimal} = usePagination<User>(rows, 20);
|
const { items, renderMinimal } = usePagination<User>(rows, 20);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export default function Home({user, group, users, entity}: Props) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, {participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x))})
|
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The group has been updated successfully!");
|
toast.success("The group has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -117,7 +117,7 @@ export default function Home({user, group, users, entity}: Props) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]})
|
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The group has been updated successfully!");
|
toast.success("The group has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -137,7 +137,7 @@ export default function Home({user, group, users, entity}: Props) {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, {name})
|
.patch(`/api/groups/${group.id}`, { name })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The classroom has been updated successfully!");
|
toast.success("The classroom has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id'));
|
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id'));
|
||||||
|
|
||||||
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants, g.admin])));
|
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants, g.admin])));
|
||||||
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users));
|
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }),
|
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }),
|
||||||
|
|||||||
@@ -2,34 +2,29 @@
|
|||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import UserDisplayList from "@/components/UserDisplayList";
|
import UserDisplayList from "@/components/UserDisplayList";
|
||||||
import IconCard from "@/components/IconCard";
|
import IconCard from "@/components/IconCard";
|
||||||
import { Module } from "@/interfaces";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import { Group, Stat, User } from "@/interfaces/user";
|
import { Group, Stat, Type, User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
import { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
import { countGroups, getGroups } from "@/utils/groups.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import { getStatsByUsers } from "@/utils/stats.be";
|
import { getStatsByUsers } from "@/utils/stats.be";
|
||||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
import { countUsers, getUser, getUsers } from "@/utils/users.be";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
BsBank,
|
BsBank,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
BsClock,
|
|
||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsPaperclip,
|
|
||||||
BsPencilSquare,
|
BsPencilSquare,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
BsPeopleFill,
|
BsPeopleFill,
|
||||||
@@ -40,61 +35,55 @@ import { ToastContainer } from "react-toastify";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
students: User[];
|
||||||
|
latestStudents: User[]
|
||||||
|
latestTeachers: User[]
|
||||||
entities: EntityWithRoles[];
|
entities: EntityWithRoles[];
|
||||||
assignments: Assignment[];
|
usersCount: { [key in Type]: number }
|
||||||
|
assignmentsCount: number;
|
||||||
stats: Stat[];
|
stats: Stat[];
|
||||||
groups: Group[];
|
groupsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
|
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
|
||||||
|
|
||||||
const users = await getUsers();
|
const students = await getUsers({ type: 'student' });
|
||||||
const entities = await getEntitiesWithRoles();
|
const usersCount = {
|
||||||
const assignments = await getAssignments();
|
student: await countUsers({ type: "student" }),
|
||||||
const stats = await getStatsByUsers(users.map((u) => u.id));
|
teacher: await countUsers({ type: "teacher" }),
|
||||||
const groups = await getGroups();
|
corporate: await countUsers({ type: "corporate" }),
|
||||||
|
mastercorporate: await countUsers({ type: "mastercorporate" }),
|
||||||
|
}
|
||||||
|
|
||||||
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
|
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 })
|
||||||
|
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 })
|
||||||
|
|
||||||
|
const entities = await getEntitiesWithRoles();
|
||||||
|
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } });
|
||||||
|
const groupsCount = await countGroups();
|
||||||
|
|
||||||
|
const stats = await getStatsByUsers(mapBy(students, 'id'));
|
||||||
|
|
||||||
|
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) };
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
|
export default function Dashboard({
|
||||||
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
|
user,
|
||||||
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
|
students,
|
||||||
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
|
latestStudents,
|
||||||
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
|
latestTeachers,
|
||||||
|
usersCount,
|
||||||
|
entities,
|
||||||
|
assignmentsCount,
|
||||||
|
stats,
|
||||||
|
groupsCount
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
|
||||||
const formattedStats = studentStats
|
|
||||||
.map((s) => ({
|
|
||||||
focus: students.find((u) => u.id === s.user)?.focus,
|
|
||||||
score: s.score,
|
|
||||||
module: s.module,
|
|
||||||
}))
|
|
||||||
.filter((f) => !!f.focus);
|
|
||||||
const bandScores = formattedStats.map((s) => ({
|
|
||||||
module: s.module,
|
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const levels: { [key in Module]: number } = {
|
|
||||||
reading: 0,
|
|
||||||
listening: 0,
|
|
||||||
writing: 0,
|
|
||||||
speaking: 0,
|
|
||||||
level: 0,
|
|
||||||
};
|
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -113,35 +102,35 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
onClick={() => router.push("/users?type=student")}
|
onClick={() => router.push("/users?type=student")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={students.length}
|
value={usersCount.student}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/users?type=teacher")}
|
onClick={() => router.push("/users?type=teacher")}
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={teachers.length}
|
value={usersCount.teacher}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
onClick={() => router.push("/users?type=corporate")}
|
onClick={() => router.push("/users?type=corporate")}
|
||||||
label="Corporates"
|
label="Corporates"
|
||||||
value={corporates.length}
|
value={usersCount.corporate}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
onClick={() => router.push("/users?type=mastercorporate")}
|
onClick={() => router.push("/users?type=mastercorporate")}
|
||||||
label="Master Corporates"
|
label="Master Corporates"
|
||||||
value={masterCorporates.length}
|
value={usersCount.mastercorporate}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPeople}
|
Icon={BsPeople}
|
||||||
onClick={() => router.push("/classrooms")}
|
onClick={() => router.push("/classrooms")}
|
||||||
label="Classrooms"
|
label="Classrooms"
|
||||||
value={groups.length}
|
value={groupsCount}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsPeopleFill}
|
<IconCard Icon={BsPeopleFill}
|
||||||
@@ -167,18 +156,18 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
Icon={BsEnvelopePaper}
|
Icon={BsEnvelopePaper}
|
||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
label="Assignments"
|
label="Assignments"
|
||||||
value={assignments.filter((a) => !a.archived).length}
|
value={assignmentsCount}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
<UserDisplayList
|
<UserDisplayList
|
||||||
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
|
users={latestStudents}
|
||||||
title="Latest Students"
|
title="Latest Students"
|
||||||
/>
|
/>
|
||||||
<UserDisplayList
|
<UserDisplayList
|
||||||
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
|
users={latestTeachers}
|
||||||
title="Latest Teachers"
|
title="Latest Teachers"
|
||||||
/>
|
/>
|
||||||
<UserDisplayList
|
<UserDisplayList
|
||||||
|
|||||||
@@ -2,22 +2,19 @@
|
|||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import UserDisplayList from "@/components/UserDisplayList";
|
import UserDisplayList from "@/components/UserDisplayList";
|
||||||
import IconCard from "@/components/IconCard";
|
import IconCard from "@/components/IconCard";
|
||||||
import { Module } from "@/interfaces";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Stat, Type, User } from "@/interfaces/user";
|
||||||
import { Group, Stat, User } from "@/interfaces/user";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getEntitiesAssignments } from "@/utils/assignments.be";
|
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { getGroupsByEntities } from "@/utils/groups.be";
|
import { countGroupsByEntities } from "@/utils/groups.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
import { calculateAverageLevel } from "@/utils/score";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import { getStatsByUsers } from "@/utils/stats.be";
|
import { getStatsByUsers } from "@/utils/stats.be";
|
||||||
import { filterAllowedUsers } from "@/utils/users.be";
|
import { countAllowedUsers, filterAllowedUsers } from "@/utils/users.be";
|
||||||
import { getEntitiesUsers } from "@/utils/users.be";
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -28,7 +25,6 @@ import {
|
|||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
BsClock,
|
BsClock,
|
||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsPaperclip,
|
|
||||||
BsPencilSquare,
|
BsPencilSquare,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
BsPeopleFill,
|
BsPeopleFill,
|
||||||
@@ -36,74 +32,47 @@ import {
|
|||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
|
userCounts: { [key in Type]: number }
|
||||||
entities: EntityWithRoles[];
|
entities: EntityWithRoles[];
|
||||||
assignments: Assignment[];
|
assignmentsCount: number;
|
||||||
stats: Stat[];
|
stats: Stat[];
|
||||||
groups: Group[];
|
groupsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/")
|
if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/")
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
const entities = await getEntitiesWithRoles(entityIDS);
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||||
const users = await filterAllowedUsers(user, entities)
|
const users = await filterAllowedUsers(user, entities)
|
||||||
|
|
||||||
const assignments = await getEntitiesAssignments(entityIDS);
|
const userCounts = await countAllowedUsers(user, entities)
|
||||||
const stats = await getStatsByUsers(users.map((u) => u.id));
|
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
|
||||||
const groups = await getGroupsByEntities(entityIDS);
|
const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
|
||||||
|
|
||||||
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
|
const stats = await getStatsByUsers(users.map((u) => u.id));
|
||||||
|
|
||||||
|
return { props: serialize({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }) };
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
|
export default function Dashboard({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }: Props) {
|
||||||
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
|
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
|
||||||
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
|
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
|
||||||
|
|
||||||
|
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
|
||||||
|
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
|
||||||
const formattedStats = studentStats
|
|
||||||
.map((s) => ({
|
|
||||||
focus: students.find((u) => u.id === s.user)?.focus,
|
|
||||||
score: s.score,
|
|
||||||
module: s.module,
|
|
||||||
}))
|
|
||||||
.filter((f) => !!f.focus);
|
|
||||||
const bandScores = formattedStats.map((s) => ({
|
|
||||||
module: s.module,
|
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const levels: { [key in Module]: number } = {
|
|
||||||
reading: 0,
|
|
||||||
listening: 0,
|
|
||||||
writing: 0,
|
|
||||||
speaking: 0,
|
|
||||||
level: 0,
|
|
||||||
};
|
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
|
||||||
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -128,21 +97,21 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
onClick={() => router.push("/users?type=student")}
|
onClick={() => router.push("/users?type=student")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={students.length}
|
value={userCounts.student}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/users?type=teacher")}
|
onClick={() => router.push("/users?type=teacher")}
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={teachers.length}
|
value={userCounts.teacher}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/classrooms")}
|
onClick={() => router.push("/classrooms")}
|
||||||
Icon={BsPeople}
|
Icon={BsPeople}
|
||||||
label="Classrooms"
|
label="Classrooms"
|
||||||
value={groups.length}
|
value={groupsCount}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsPeopleFill}
|
<IconCard Icon={BsPeopleFill}
|
||||||
@@ -152,13 +121,22 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
|
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
|
||||||
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
|
{allowedEntityStatistics.length > 0 && (
|
||||||
|
<IconCard Icon={BsPersonFillGear}
|
||||||
|
onClick={() => router.push("/statistical")}
|
||||||
|
label="Entity Statistics"
|
||||||
|
value={allowedEntityStatistics.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{allowedStudentPerformance.length > 0 && (
|
||||||
<IconCard Icon={BsPersonFillGear}
|
<IconCard Icon={BsPersonFillGear}
|
||||||
onClick={() => router.push("/users/performance")}
|
onClick={() => router.push("/users/performance")}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={students.length}
|
value={userCounts.student}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsClock}
|
Icon={BsClock}
|
||||||
label="Expiration Date"
|
label="Expiration Date"
|
||||||
@@ -170,7 +148,7 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
className="col-span-2"
|
className="col-span-2"
|
||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
label="Assignments"
|
label="Assignments"
|
||||||
value={assignments.filter((a) => !a.archived).length}
|
value={assignmentsCount}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,34 +2,29 @@
|
|||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import UserDisplayList from "@/components/UserDisplayList";
|
import UserDisplayList from "@/components/UserDisplayList";
|
||||||
import IconCard from "@/components/IconCard";
|
import IconCard from "@/components/IconCard";
|
||||||
import { Module } from "@/interfaces";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import { Group, Stat, User } from "@/interfaces/user";
|
import { Group, Stat, Type, User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
import { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
import { countGroups, getGroups } from "@/utils/groups.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import { getStatsByUsers } from "@/utils/stats.be";
|
import { getStatsByUsers } from "@/utils/stats.be";
|
||||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
import { countUsers, getUser, getUsers } from "@/utils/users.be";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
BsBank,
|
BsBank,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
BsClock,
|
|
||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsPaperclip,
|
|
||||||
BsPencilSquare,
|
BsPencilSquare,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
BsPeopleFill,
|
BsPeopleFill,
|
||||||
@@ -40,61 +35,55 @@ import { ToastContainer } from "react-toastify";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
students: User[];
|
||||||
|
latestStudents: User[]
|
||||||
|
latestTeachers: User[]
|
||||||
entities: EntityWithRoles[];
|
entities: EntityWithRoles[];
|
||||||
assignments: Assignment[];
|
usersCount: { [key in Type]: number }
|
||||||
|
assignmentsCount: number;
|
||||||
stats: Stat[];
|
stats: Stat[];
|
||||||
groups: Group[];
|
groupsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
|
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
|
||||||
|
|
||||||
const users = await getUsers();
|
const students = await getUsers({ type: 'student' });
|
||||||
const entities = await getEntitiesWithRoles();
|
const usersCount = {
|
||||||
const assignments = await getAssignments();
|
student: await countUsers({ type: "student" }),
|
||||||
const stats = await getStatsByUsers(users.map((u) => u.id));
|
teacher: await countUsers({ type: "teacher" }),
|
||||||
const groups = await getGroups();
|
corporate: await countUsers({ type: "corporate" }),
|
||||||
|
mastercorporate: await countUsers({ type: "mastercorporate" }),
|
||||||
|
}
|
||||||
|
|
||||||
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
|
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 })
|
||||||
|
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 })
|
||||||
|
|
||||||
|
const entities = await getEntitiesWithRoles();
|
||||||
|
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } });
|
||||||
|
const groupsCount = await countGroups();
|
||||||
|
|
||||||
|
const stats = await getStatsByUsers(mapBy(students, 'id'));
|
||||||
|
|
||||||
|
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) };
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
|
export default function Dashboard({
|
||||||
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
|
user,
|
||||||
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
|
students,
|
||||||
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
|
latestStudents,
|
||||||
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
|
latestTeachers,
|
||||||
|
usersCount,
|
||||||
|
entities,
|
||||||
|
assignmentsCount,
|
||||||
|
stats,
|
||||||
|
groupsCount
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
|
||||||
const formattedStats = studentStats
|
|
||||||
.map((s) => ({
|
|
||||||
focus: students.find((u) => u.id === s.user)?.focus,
|
|
||||||
score: s.score,
|
|
||||||
module: s.module,
|
|
||||||
}))
|
|
||||||
.filter((f) => !!f.focus);
|
|
||||||
const bandScores = formattedStats.map((s) => ({
|
|
||||||
module: s.module,
|
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const levels: { [key in Module]: number } = {
|
|
||||||
reading: 0,
|
|
||||||
listening: 0,
|
|
||||||
writing: 0,
|
|
||||||
speaking: 0,
|
|
||||||
level: 0,
|
|
||||||
};
|
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -113,35 +102,35 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
onClick={() => router.push("/users?type=student")}
|
onClick={() => router.push("/users?type=student")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={students.length}
|
value={usersCount.student}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/users?type=teacher")}
|
onClick={() => router.push("/users?type=teacher")}
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={teachers.length}
|
value={usersCount.teacher}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
onClick={() => router.push("/users?type=corporate")}
|
onClick={() => router.push("/users?type=corporate")}
|
||||||
label="Corporates"
|
label="Corporates"
|
||||||
value={corporates.length}
|
value={usersCount.corporate}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
onClick={() => router.push("/users?type=mastercorporate")}
|
onClick={() => router.push("/users?type=mastercorporate")}
|
||||||
label="Master Corporates"
|
label="Master Corporates"
|
||||||
value={masterCorporates.length}
|
value={usersCount.mastercorporate}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPeople}
|
Icon={BsPeople}
|
||||||
onClick={() => router.push("/classrooms")}
|
onClick={() => router.push("/classrooms")}
|
||||||
label="Classrooms"
|
label="Classrooms"
|
||||||
value={groups.length}
|
value={groupsCount}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsPeopleFill}
|
<IconCard Icon={BsPeopleFill}
|
||||||
@@ -160,25 +149,25 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
<IconCard Icon={BsPersonFillGear}
|
<IconCard Icon={BsPersonFillGear}
|
||||||
onClick={() => router.push("/users/performance")}
|
onClick={() => router.push("/users/performance")}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={students.length}
|
value={usersCount.student}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsEnvelopePaper}
|
Icon={BsEnvelopePaper}
|
||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
label="Assignments"
|
label="Assignments"
|
||||||
value={assignments.filter((a) => !a.archived).length}
|
value={assignmentsCount}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
<UserDisplayList
|
<UserDisplayList
|
||||||
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
|
users={latestStudents}
|
||||||
title="Latest Students"
|
title="Latest Students"
|
||||||
/>
|
/>
|
||||||
<UserDisplayList
|
<UserDisplayList
|
||||||
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
|
users={latestTeachers}
|
||||||
title="Latest Teachers"
|
title="Latest Teachers"
|
||||||
/>
|
/>
|
||||||
<UserDisplayList
|
<UserDisplayList
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { redirect } from "@/utils";
|
import { redirect } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
return redirect(`/dashboard/${user.type}`)
|
return redirect(`/dashboard/${user.type}`)
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ interface Props {
|
|||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
|
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
@@ -76,16 +76,7 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
|
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
|
||||||
|
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
|
||||||
const UserDisplay = (displayUser: User) => (
|
|
||||||
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -131,12 +122,14 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
|
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
|
||||||
|
{allowedStudentPerformance.length > 0 && (
|
||||||
<IconCard Icon={BsPersonFillGear}
|
<IconCard Icon={BsPersonFillGear}
|
||||||
onClick={() => router.push("/users/performance")}
|
onClick={() => router.push("/users/performance")}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={students.length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{allowedEntityStatistics.length > 0 && (
|
{allowedEntityStatistics.length > 0 && (
|
||||||
<IconCard Icon={BsPersonFillGear}
|
<IconCard Icon={BsPersonFillGear}
|
||||||
onClick={() => router.push("/statistical")}
|
onClick={() => router.push("/statistical")}
|
||||||
|
|||||||
@@ -5,38 +5,38 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
|
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
|
||||||
import ModuleBadge from "@/components/ModuleBadge";
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import {Session} from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import {Grading} from "@/interfaces";
|
import { Grading } from "@/interfaces";
|
||||||
import {EntityWithRoles} from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { InviteWithEntity } from "@/interfaces/invite";
|
import { InviteWithEntity } from "@/interfaces/invite";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import {findBy, mapBy, redirect, serialize} from "@/utils";
|
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {activeAssignmentFilter} from "@/utils/assignments";
|
import { activeAssignmentFilter } from "@/utils/assignments";
|
||||||
import {getAssignmentsByAssignee} from "@/utils/assignments.be";
|
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
|
||||||
import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be";
|
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
|
||||||
import {getExamsByIds} from "@/utils/exams.be";
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
import {getGradingSystemByEntity} from "@/utils/grading.be";
|
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
||||||
import {convertInvitersToEntity, getInvitesByInvitee} from "@/utils/invites.be";
|
import { convertInvitersToEntity, getInvitesByInvitee } from "@/utils/invites.be";
|
||||||
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import {getGradingLabel} from "@/utils/score";
|
import { getGradingLabel } from "@/utils/score";
|
||||||
import {getSessionsByUser} from "@/utils/sessions.be";
|
import { getSessionsByUser } from "@/utils/sessions.be";
|
||||||
import {averageScore} from "@/utils/stats";
|
import { averageScore } from "@/utils/stats";
|
||||||
import {getStatsByUser} from "@/utils/stats.be";
|
import { getStatsByUser } from "@/utils/stats.be";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
import { BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar } from "react-icons/bs";
|
||||||
import {ToastContainer} from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -49,9 +49,9 @@ interface Props {
|
|||||||
grading: Grading;
|
grading: Grading;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "student"]))
|
if (!checkAccess(user, ["admin", "developer", "student"]))
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
@@ -59,7 +59,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
|
|
||||||
const entities = await getEntitiesWithRoles(entityIDS);
|
const entities = await getEntitiesWithRoles(entityIDS);
|
||||||
const assignments = await getAssignmentsByAssignee(user.id, {archived: {$ne: true}});
|
const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } });
|
||||||
const stats = await getStatsByUser(user.id);
|
const stats = await getStatsByUser(user.id);
|
||||||
const sessions = await getSessionsByUser(user.id, 10);
|
const sessions = await getSessionsByUser(user.id, 10);
|
||||||
const invites = await getInvitesByInvitee(user.id);
|
const invites = await getInvitesByInvitee(user.id);
|
||||||
@@ -69,16 +69,16 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
|
|
||||||
const examIDs = uniqBy(
|
const examIDs = uniqBy(
|
||||||
assignments.flatMap((a) =>
|
assignments.flatMap((a) =>
|
||||||
a.exams.filter((e) => e.assignee === user.id).map((e) => ({module: e.module, id: e.id, key: `${e.module}_${e.id}`})),
|
a.exams.filter((e) => e.assignee === user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })),
|
||||||
),
|
),
|
||||||
"key",
|
"key",
|
||||||
);
|
);
|
||||||
const exams = await getExamsByIds(examIDs);
|
const exams = await getExamsByIds(examIDs);
|
||||||
|
|
||||||
return {props: serialize({user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading})};
|
return { props: serialize({ user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading }) };
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: Props) {
|
export default function Dashboard({ user, entities, assignments, stats, invites, grading, sessions, exams }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const dispatch = useExamStore((state) => state.dispatch);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
@@ -90,11 +90,13 @@ export default function Dashboard({user, entities, assignments, stats, invites,
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (assignmentExams.every((x) => !!x)) {
|
if (assignmentExams.every((x) => !!x)) {
|
||||||
dispatch({type: "INIT_EXAM", payload: {
|
dispatch({
|
||||||
|
type: "INIT_EXAM", payload: {
|
||||||
exams: assignmentExams.sort(sortByModule),
|
exams: assignmentExams.sort(sortByModule),
|
||||||
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
||||||
assignment
|
assignment
|
||||||
}})
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ import { uniqBy } from "lodash";
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill } from "react-icons/bs";
|
import { BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill, BsPersonFillGear } from "react-icons/bs";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
import { filterAllowedUsers } from "@/utils/users.be";
|
import { filterAllowedUsers } from "@/utils/users.be";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -38,13 +39,13 @@ interface Props {
|
|||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "teacher"]))
|
if (!checkAccess(user, ["admin", "developer", "teacher"]))
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
const entities = await getEntitiesWithRoles(entityIDS);
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||||
const users = await filterAllowedUsers(user, entities)
|
const users = await filterAllowedUsers(user, entities)
|
||||||
|
|
||||||
const assignments = await getEntitiesAssignments(entityIDS);
|
const assignments = await getEntitiesAssignments(entityIDS);
|
||||||
@@ -58,40 +59,8 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
|
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
|
||||||
const formattedStats = studentStats
|
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
|
||||||
.map((s) => ({
|
|
||||||
focus: students.find((u) => u.id === s.user)?.focus,
|
|
||||||
score: s.score,
|
|
||||||
module: s.module,
|
|
||||||
}))
|
|
||||||
.filter((f) => !!f.focus);
|
|
||||||
const bandScores = formattedStats.map((s) => ({
|
|
||||||
module: s.module,
|
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const levels: { [key in Module]: number } = {
|
|
||||||
reading: 0,
|
|
||||||
listening: 0,
|
|
||||||
writing: 0,
|
|
||||||
speaking: 0,
|
|
||||||
level: 0,
|
|
||||||
};
|
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
|
||||||
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -128,7 +97,22 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
|
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
|
||||||
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
|
{allowedStudentPerformance.length > 0 && (
|
||||||
|
<IconCard Icon={BsPersonFillGear}
|
||||||
|
onClick={() => router.push("/users/performance")}
|
||||||
|
label="Student Performance"
|
||||||
|
value={students.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{allowedEntityStatistics.length > 0 && (
|
||||||
|
<IconCard Icon={BsPersonFillGear}
|
||||||
|
onClick={() => router.push("/statistical")}
|
||||||
|
label="Entity Statistics"
|
||||||
|
value={allowedEntityStatistics.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsEnvelopePaper}
|
Icon={BsEnvelopePaper}
|
||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{ label: "Edit Role Permissions", key: "edit_role_permissions" },
|
{ label: "Edit Role Permissions", key: "edit_role_permissions" },
|
||||||
{ label: "Assign Role to User", key: "assign_to_role" },
|
{ label: "Assign Role to User", key: "assign_to_role" },
|
||||||
{ label: "Delete Entity Role", key: "delete_entity_role" },
|
{ label: "Delete Entity Role", key: "delete_entity_role" },
|
||||||
{ label: "Download Statistics Report", key: "download_statistics_report" }
|
{ label: "Download Statistics Report", key: "download_statistics_report" },
|
||||||
|
{ label: "Edit Grading System", key: "edit_grading_system" },
|
||||||
|
{ label: "View Student Performance", key: "view_student_performance" }
|
||||||
]
|
]
|
||||||
|
|
||||||
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { redirect } from "@/utils";
|
import { redirect } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user || !user.isVerified) return redirect("/login")
|
||||||
|
|
||||||
return redirect(`/dashboard/${user.type}`)
|
return redirect(`/dashboard/${user.type}`)
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:
|
|||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
||||||
const destination = !query.destination ? "/" : Buffer.from(query.destination as string, 'base64').toString()
|
const destination = !query.destination ? "/" : Buffer.from(query.destination as string, 'base64').toString()
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (user) return redirect(destination)
|
if (user && user.isVerified) return redirect(destination)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: { user: null, destination },
|
props: { user: null, destination },
|
||||||
@@ -39,13 +39,10 @@ export default function Login({ destination = "/" }: { destination?: string }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOfficialExamLogin = useMemo(() => destination.startsWith("/official-exam"), [destination])
|
const isOfficialExamLogin = useMemo(() => destination.startsWith("/official-exam"), [destination])
|
||||||
|
|
||||||
const { user, mutateUser } = useUser({
|
const { user, mutateUser } = useUser();
|
||||||
redirectTo: destination,
|
|
||||||
redirectIfFound: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) router.push(destination);
|
if (user && user.isVerified) router.push(destination);
|
||||||
}, [router, user, destination]);
|
}, [router, user, destination]);
|
||||||
|
|
||||||
const forgotPassword = () => {
|
const forgotPassword = () => {
|
||||||
@@ -173,7 +170,7 @@ export default function Login({ destination = "/" }: { destination?: string }) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* {user && !user.isVerified && <EmailVerification user={user} isLoading={isLoading} setIsLoading={setIsLoading} />} */}
|
{user && !user.isVerified && <EmailVerification user={user} isLoading={isLoading} setIsLoading={setIsLoading} />}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import PaymentDue from "./(status)/PaymentDue";
|
import PaymentDue from "./(status)/PaymentDue";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { redirect } from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
|
import { getEntities } from "@/utils/entities.be";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
|
const entities = await getEntities(isAdmin(user) ? undefined : entityIDs)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user},
|
props: serialize({ user, entities }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Home() {
|
interface Props {
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
user: User,
|
||||||
|
entities: EntityWithRoles[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({ user, entities }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,7 +43,7 @@ export default function Home() {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
{user && <PaymentDue user={user} reload={router.reload} />}
|
<PaymentDue entities={entities} user={user} reload={router.reload} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
|
|||||||
const entitiesAllowCreateUsers = useAllowedEntities(user, entities, 'create_user_batch')
|
const entitiesAllowCreateUsers = useAllowedEntities(user, entities, 'create_user_batch')
|
||||||
const entitiesAllowCreateCode = useAllowedEntities(user, entities, 'create_code')
|
const entitiesAllowCreateCode = useAllowedEntities(user, entities, 'create_code')
|
||||||
const entitiesAllowCreateCodes = useAllowedEntities(user, entities, 'create_code_batch')
|
const entitiesAllowCreateCodes = useAllowedEntities(user, entities, 'create_code_batch')
|
||||||
|
const entitiesAllowEditGrading = useAllowedEntities(user, entities, 'edit_grading_system')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -93,10 +94,21 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
|
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
|
||||||
<BatchCodeGenerator user={user} users={allUsers} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
|
<BatchCodeGenerator
|
||||||
|
entities={entitiesAllowCreateCodes}
|
||||||
|
user={user}
|
||||||
|
users={allUsers}
|
||||||
|
permissions={permissions}
|
||||||
|
onFinish={() => setModalOpen(undefined)}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
|
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
|
||||||
<CodeGenerator user={user} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
|
<CodeGenerator
|
||||||
|
entities={entitiesAllowCreateCode}
|
||||||
|
user={user}
|
||||||
|
permissions={permissions}
|
||||||
|
onFinish={() => setModalOpen(undefined)}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
|
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
|
||||||
<UserCreator
|
<UserCreator
|
||||||
@@ -111,7 +123,7 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
|
|||||||
<CorporateGradingSystem
|
<CorporateGradingSystem
|
||||||
user={user}
|
user={user}
|
||||||
entitiesGrading={entitiesGrading}
|
entitiesGrading={entitiesGrading}
|
||||||
entities={entities}
|
entities={entitiesAllowEditGrading}
|
||||||
mutate={() => router.replace(router.asPath)}
|
mutate={() => router.replace(router.asPath)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -159,6 +171,7 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
|
|||||||
color="purple"
|
color="purple"
|
||||||
className="w-full h-full col-span-2"
|
className="w-full h-full col-span-2"
|
||||||
onClick={() => setModalOpen("gradingSystem")}
|
onClick={() => setModalOpen("gradingSystem")}
|
||||||
|
disabled={entitiesAllowEditGrading.length === 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {BsArrowClockwise, BsChevronLeft, BsChevronRight, BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
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 { LinearScale, Chart as ChartJS, CategoryScale, PointElement, LineElement, Legend, Tooltip, LineController } from "chart.js";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import {averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate} from "@/utils/stats";
|
import { averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate } from "@/utils/stats";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {ToastContainer} from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import {capitalize, Dictionary} from "lodash";
|
import { capitalize, Dictionary } from "lodash";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||||
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
|
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
|
||||||
import {Chart} from "react-chartjs-2";
|
import { Chart } from "react-chartjs-2";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import DatePicker from "react-datepicker";
|
import DatePicker from "react-datepicker";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Group, Stat, User} from "@/interfaces/user";
|
import { Group, Stat, User } from "@/interfaces/user";
|
||||||
import {Divider} from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import Badge from "@/components/Low/Badge";
|
import Badge from "@/components/Low/Badge";
|
||||||
import { mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, 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 { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||||
@@ -38,7 +38,7 @@ ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineCont
|
|||||||
|
|
||||||
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
|
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
|
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({user, entities, users, groups}),
|
props: serialize({ user, entities, users, groups }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ export default function Stats({ user, entities, users, groups }: Props) {
|
|||||||
const [startDate, setStartDate] = useState<Date | null>(moment(new Date()).subtract(1, "weeks").toDate());
|
const [startDate, setStartDate] = useState<Date | null>(moment(new Date()).subtract(1, "weeks").toDate());
|
||||||
const [endDate, setEndDate] = useState<Date | null>(new Date());
|
const [endDate, setEndDate] = useState<Date | null>(new Date());
|
||||||
const [initialStatDate, setInitialStatDate] = useState<Date>();
|
const [initialStatDate, setInitialStatDate] = useState<Date>();
|
||||||
|
const [selectedEntity, setSelectedEntity] = useState<string>()
|
||||||
|
|
||||||
const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState<Date | null>(new Date());
|
const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState<Date | null>(new Date());
|
||||||
const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState<Date | null>(new Date());
|
const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState<Date | null>(new Date());
|
||||||
@@ -73,7 +74,12 @@ export default function Stats({ user, entities, users, groups }: Props) {
|
|||||||
const [dailyScoreDate, setDailyScoreDate] = useState<Date | null>(new Date());
|
const [dailyScoreDate, setDailyScoreDate] = useState<Date | null>(new Date());
|
||||||
const [intervalDates, setIntervalDates] = useState<Date[]>([]);
|
const [intervalDates, setIntervalDates] = useState<Date[]>([]);
|
||||||
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(statsUserId, !statsUserId);
|
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(() => {
|
useEffect(() => {
|
||||||
setInitialStatDate(
|
setInitialStatDate(
|
||||||
@@ -181,26 +187,24 @@ export default function Stats({ user, entities, users, groups }: Props) {
|
|||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="w-full flex justify-between gap-4 items-center">
|
<div className="w-full flex justify-between gap-4 items-center">
|
||||||
|
{["corporate", "teacher", "mastercorporate", "developer", "admin"].includes(user.type) && (
|
||||||
<>
|
<>
|
||||||
{(user.type === "developer" || user.type === "admin") && (
|
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
options={entities.map(e => ({ value: e.id, label: e.label }))}
|
||||||
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
onChange={(value) => setSelectedEntity(value?.value || undefined)}
|
||||||
onChange={(value) => setStatsUserId(value?.value || user.id)}
|
placeholder="Select an entity..."
|
||||||
|
isClearable
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (
|
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
options={users
|
options={students
|
||||||
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` }))}
|
||||||
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
|
||||||
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
|
||||||
onChange={(value) => setStatsUserId(value?.value || user.id)}
|
onChange={(value) => setStatsUserId(value?.value || user.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.length > 0 && (
|
{stats.length > 0 && (
|
||||||
@@ -244,8 +248,7 @@ export default function Stats({ user, entities, users, groups }: Props) {
|
|||||||
<div className="w-full grid grid-cols-3 gap-4 items-center">
|
<div className="w-full grid grid-cols-3 gap-4 items-center">
|
||||||
{[...Array(31).keys()].map((day) => {
|
{[...Array(31).keys()].map((day) => {
|
||||||
const date = moment(
|
const date = moment(
|
||||||
`${(day + 1).toString().padStart(2, "0")}/${
|
`${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1
|
||||||
moment(monthlyOverallScoreDate).get("month") + 1
|
|
||||||
}/${moment(monthlyOverallScoreDate).get("year")}`,
|
}/${moment(monthlyOverallScoreDate).get("year")}`,
|
||||||
"DD/MM/yyyy",
|
"DD/MM/yyyy",
|
||||||
);
|
);
|
||||||
@@ -319,8 +322,7 @@ export default function Stats({ user, entities, users, groups }: Props) {
|
|||||||
labels: [...Array(31).keys()]
|
labels: [...Array(31).keys()]
|
||||||
.map((day) => {
|
.map((day) => {
|
||||||
const date = moment(
|
const date = moment(
|
||||||
`${(day + 1).toString().padStart(2, "0")}/${
|
`${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1
|
||||||
moment(monthlyOverallScoreDate).get("month") + 1
|
|
||||||
}/${moment(monthlyOverallScoreDate).get("year")}`,
|
}/${moment(monthlyOverallScoreDate).get("year")}`,
|
||||||
"DD/MM/yyyy",
|
"DD/MM/yyyy",
|
||||||
);
|
);
|
||||||
@@ -339,8 +341,7 @@ export default function Stats({ user, entities, users, groups }: Props) {
|
|||||||
data: [...Array(31).keys()]
|
data: [...Array(31).keys()]
|
||||||
.map((day) => {
|
.map((day) => {
|
||||||
const date = moment(
|
const date = moment(
|
||||||
`${(day + 1).toString().padStart(2, "0")}/${
|
`${(day + 1).toString().padStart(2, "0")}/${moment(monthlyOverallScoreDate).get("month") + 1
|
||||||
moment(monthlyOverallScoreDate).get("month") + 1
|
|
||||||
}/${moment(monthlyOverallScoreDate).get("year")}`,
|
}/${moment(monthlyOverallScoreDate).get("year")}`,
|
||||||
"DD/MM/yyyy",
|
"DD/MM/yyyy",
|
||||||
);
|
);
|
||||||
@@ -396,7 +397,7 @@ export default function Stats({ user, entities, users, groups }: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<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)
|
.sort(sortByModule)
|
||||||
.map(({module, score}) => (
|
.map(({ module, score }) => (
|
||||||
<div className="flex flex-col gap-2" key={module}>
|
<div className="flex flex-col gap-2" key={module}>
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers, {userHashStudent} from "@/hooks/useUsers";
|
import useUsers, { userHashStudent } from "@/hooks/useUsers";
|
||||||
import {Group, Stat, StudentUser, User} from "@/interfaces/user";
|
import { Group, Stat, StudentUser, User } from "@/interfaces/user";
|
||||||
import {getUserCompanyName} from "@/resources/user";
|
import { getUserCompanyName } from "@/resources/user";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsArrowLeft, BsArrowRepeat, BsChevronLeft} from "react-icons/bs";
|
import { BsArrowLeft, BsArrowRepeat, BsChevronLeft } from "react-icons/bs";
|
||||||
import { mapBy, serialize } from "@/utils";
|
import { mapBy, serialize } from "@/utils";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||||
import { getEntities } from "@/utils/entities.be";
|
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { Entity } from "@/interfaces/entity";
|
import { Entity } from "@/interfaces/entity";
|
||||||
import { getParticipantGroups, getParticipantsGroups } from "@/utils/groups.be";
|
import { getParticipantGroups, getParticipantsGroups } from "@/utils/groups.be";
|
||||||
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
|
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
|
||||||
@@ -21,21 +21,25 @@ import Layout from "@/components/High/Layout";
|
|||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { redirect } from "@/utils";
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, 'id')
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
|
|
||||||
const entities = await getEntities(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs)
|
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs)
|
||||||
|
const allowedEntities = findAllowedEntities(user, entities, "view_student_performance")
|
||||||
|
|
||||||
|
if (allowedEntities.length === 0) return redirect("/")
|
||||||
|
|
||||||
const students = await (checkAccess(user, ["admin", 'developer'])
|
const students = await (checkAccess(user, ["admin", 'developer'])
|
||||||
? getUsers({type: 'student'})
|
? getUsers({ type: 'student' })
|
||||||
: getEntitiesUsers(entityIDs, {type: 'student'})
|
: getEntitiesUsers(mapBy(allowedEntities, 'id'), { type: 'student' })
|
||||||
)
|
)
|
||||||
const groups = await getParticipantsGroups(mapBy(students, 'id'))
|
const groups = await getParticipantsGroups(mapBy(students, 'id'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({user, students, entities, groups}),
|
props: serialize({ user, students, entities, groups }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -46,8 +50,8 @@ interface Props {
|
|||||||
groups: Group[]
|
groups: Group[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const StudentPerformance = ({user, students, entities, groups}: Props) => {
|
const StudentPerformance = ({ user, students, entities, groups }: Props) => {
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
const { data: stats } = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -79,7 +83,7 @@ const StudentPerformance = ({user, students, entities, groups}: Props) => {
|
|||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="font-bold text-2xl">Student Performance ({ students.length })</h2>
|
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2>
|
||||||
</div>
|
</div>
|
||||||
<StudentPerformanceList items={performanceStudents} stats={stats} />
|
<StudentPerformanceList items={performanceStudents} stats={stats} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export type RolePermission =
|
|||||||
"view_code_list" |
|
"view_code_list" |
|
||||||
"delete_code" |
|
"delete_code" |
|
||||||
"view_statistics" |
|
"view_statistics" |
|
||||||
"download_statistics_report"
|
"download_statistics_report" |
|
||||||
|
"edit_grading_system" |
|
||||||
|
"view_student_performance"
|
||||||
|
|
||||||
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
||||||
"view_students",
|
"view_students",
|
||||||
@@ -128,5 +130,7 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
|
|||||||
"view_code_list",
|
"view_code_list",
|
||||||
"delete_code",
|
"delete_code",
|
||||||
"view_statistics",
|
"view_statistics",
|
||||||
"download_statistics_report"
|
"download_statistics_report",
|
||||||
|
"edit_grading_system",
|
||||||
|
"view_student_performance"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export const getEntitiesAssignments = async (ids: string[]) => {
|
|||||||
.toArray();
|
.toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const countEntitiesAssignments = async (ids: string[], filter?: object) => {
|
||||||
|
return await db
|
||||||
|
.collection("assignments")
|
||||||
|
.countDocuments({ entity: { $in: ids }, ...(filter || {}) })
|
||||||
|
};
|
||||||
|
|
||||||
export const getAssignmentsForCorporates = async (userType: Type, idsList: string[], startDate?: Date, endDate?: Date) => {
|
export const getAssignmentsForCorporates = async (userType: Type, idsList: string[], startDate?: Date, endDate?: Date) => {
|
||||||
const assigners = await Promise.all(
|
const assigners = await Promise.all(
|
||||||
idsList.map(async (id) => {
|
idsList.map(async (id) => {
|
||||||
|
|||||||
@@ -173,3 +173,9 @@ export const getGroupsByEntities = async (ids: string[]): Promise<WithEntity<Gro
|
|||||||
{ $match: { entity: { $in: ids } } },
|
{ $match: { entity: { $in: ids } } },
|
||||||
...addEntityToGroupPipeline
|
...addEntityToGroupPipeline
|
||||||
]).toArray()
|
]).toArray()
|
||||||
|
|
||||||
|
export const countGroups = async () =>
|
||||||
|
await db.collection("groups").countDocuments({})
|
||||||
|
|
||||||
|
export const countGroupsByEntities = async (ids: string[]) =>
|
||||||
|
await db.collection("groups").countDocuments({ entity: { $in: ids } })
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ 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) {
|
export async function getUsers(filter?: object, limit = 0, sort = {}) {
|
||||||
return await db
|
return await db
|
||||||
.collection("users")
|
.collection("users")
|
||||||
.find<User>(filter || {}, { projection: { _id: 0 } })
|
.find<User>(filter || {}, { projection: { _id: 0 } })
|
||||||
|
.limit(limit)
|
||||||
|
.sort(sort)
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
export async function countUsers(filter?: object) {
|
||||||
|
return await db
|
||||||
|
.collection("users")
|
||||||
|
.countDocuments(filter || {})
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserWithEntity(id: string): Promise<WithEntities<User> | undefined> {
|
export async function getUserWithEntity(id: string): Promise<WithEntities<User> | undefined> {
|
||||||
const user = await db.collection("users").findOne<User>({ id: id }, { projection: { _id: 0 } });
|
const user = await db.collection("users").findOne<User>({ id: id }, { projection: { _id: 0 } });
|
||||||
@@ -68,8 +75,8 @@ export async function getEntitiesUsers(ids: string[], filter?: object, limit?: n
|
|||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countEntitiesUsers(ids: string[]) {
|
export async function countEntitiesUsers(ids: string[], filter?: object) {
|
||||||
return await db.collection("users").countDocuments({ "entities.id": { $in: ids } });
|
return await db.collection("users").countDocuments({ "entities.id": { $in: ids }, ...(filter || {}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLinkedUsers(
|
export async function getLinkedUsers(
|
||||||
@@ -154,3 +161,17 @@ export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]
|
|||||||
|
|
||||||
return [...students, ...teachers, ...corporates, ...masterCorporates]
|
return [...students, ...teachers, ...corporates, ...masterCorporates]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const countAllowedUsers = async (user: User, entities: EntityWithRoles[]) => {
|
||||||
|
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
|
||||||
|
const teachersAllowedEntities = findAllowedEntities(user, entities, 'view_teachers')
|
||||||
|
const corporateAllowedEntities = findAllowedEntities(user, entities, 'view_corporates')
|
||||||
|
const masterCorporateAllowedEntities = findAllowedEntities(user, entities, 'view_mastercorporates')
|
||||||
|
|
||||||
|
const student = await countEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
|
||||||
|
const teacher = await countEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" })
|
||||||
|
const corporate = await countEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" })
|
||||||
|
const masterCorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" })
|
||||||
|
|
||||||
|
return { student, teacher, corporate, masterCorporate }
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ export const getUserName = (user?: User) => {
|
|||||||
return user.name;
|
return user.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isAdmin = (user: User) => ["admin", "developer"].includes(user.type)
|
export const isAdmin = (user: User) => ["admin", "developer"].includes(user?.type)
|
||||||
|
|||||||
Reference in New Issue
Block a user