Merged develop into feature/ExamGenRework

This commit is contained in:
carlos.mesquita
2024-12-13 21:31:10 +00:00
50 changed files with 937 additions and 815 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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 && (

View 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>
)
}

View File

@@ -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}

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -17,6 +17,7 @@ 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)} />*/}

View File

@@ -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"

View File

@@ -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;
@@ -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"

View File

@@ -2,7 +2,7 @@ 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 };
} }

View 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 };
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -19,6 +19,8 @@ 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]+)*$/);
@@ -59,10 +61,11 @@ 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>(
@@ -71,6 +74,7 @@ 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",
@@ -158,8 +162,9 @@ export default function BatchCodeGenerator({user, users, permissions, onFinish}:
.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) {
@@ -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

View File

@@ -14,6 +14,9 @@ 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[] };
@@ -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,7 +77,7 @@ 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!`, {
@@ -100,7 +106,18 @@ 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)}
@@ -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>

View File

@@ -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)}>

View File

@@ -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>
</> </>
); );
} }

View File

@@ -3,7 +3,6 @@ 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";

View File

@@ -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"]) && (

View File

@@ -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";

View File

@@ -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;
} }

View File

@@ -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;
} }

View 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());
}

View File

@@ -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) {

View 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 });
}

View File

@@ -8,6 +8,8 @@ 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);
@@ -47,10 +49,9 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
const codeDoc = await db.collection("codes").findOne<Code>({ code }); const codeDoc = await db.collection("codes").findOne<Code>({ code });
if (code && code.length > 0 && !!codeDoc) { if (code && code.length > 0 && !codeDoc)
res.status(400).json({error: "Invalid Code!"}); return res.status(400).json({ error: "Invalid Code!" });
return;
}
createUserWithEmailAndPassword(auth, email.toLowerCase(), password) createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
@@ -72,16 +73,21 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
...(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;

View File

@@ -24,7 +24,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
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) => {

View File

@@ -10,12 +10,12 @@ 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";
@@ -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);

View File

@@ -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 }),

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -6,7 +6,7 @@ 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);

View File

@@ -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")}

View File

@@ -51,7 +51,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", "student"])) if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/") return redirect("/")
@@ -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");
} }

View File

@@ -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")}

View File

@@ -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[] = [

View File

@@ -6,7 +6,7 @@ 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);

View File

@@ -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>
</> </>

View File

@@ -6,19 +6,30 @@ 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} />
</> </>
); );
} }

View File

@@ -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>

View File

@@ -4,7 +4,7 @@ import {BsArrowClockwise, BsChevronLeft, BsChevronRight, BsFileEarmarkText, BsPe
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";
@@ -25,7 +25,7 @@ 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";
@@ -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());
@@ -75,6 +76,11 @@ export default function Stats({ user, entities, users, groups }: Props) {
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(
stats stats
@@ -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",
); );

View File

@@ -10,8 +10,8 @@ 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";
@@ -27,10 +27,14 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) =
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'))

View File

@@ -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"
] ]

View File

@@ -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) => {

View File

@@ -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 } })

View File

@@ -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 }
}

View File

@@ -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)