Compare commits
26 Commits
addedAcces
...
vocabulary
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25aef3afdf | ||
|
|
df84aaadf4 | ||
|
|
2789660e8a | ||
|
|
6c7d189957 | ||
|
|
31f2a21a76 | ||
|
|
c49b1c8070 | ||
|
|
655e019bf6 | ||
|
|
d7a8f496c0 | ||
|
|
5e363e9951 | ||
|
|
3370f3c648 | ||
|
|
d77336374d | ||
|
|
e765dea106 | ||
|
|
75fb9490e0 | ||
|
|
3ef7998193 | ||
|
|
32cd8495d6 | ||
|
|
4e3cfec9e8 | ||
|
|
ba8cc342b1 | ||
|
|
dd8f821e35 | ||
|
|
a4ef2222e2 | ||
|
|
93d9e49358 | ||
|
|
5d0a3acbee | ||
|
|
340ff5a30a | ||
|
|
37908423eb | ||
|
|
b388ee399f | ||
|
|
4ac11df6ae | ||
|
|
14e2702aca |
@@ -114,5 +114,6 @@
|
||||
"husky": "^8.0.3",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import validateBlanks from "../validateBlanks";
|
||||
import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
interface Word {
|
||||
letter: string;
|
||||
@@ -72,6 +73,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
...local,
|
||||
text: blanksState.text,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -145,6 +147,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -189,6 +192,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -217,6 +221,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -234,6 +239,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
import MCOption from "./MCOption";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
@@ -69,6 +70,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...local,
|
||||
text: blanksState.text,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -139,6 +141,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -168,6 +171,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -217,6 +221,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...prev,
|
||||
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -234,6 +239,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
|
||||
blanksMissingWords.forEach(blank => {
|
||||
const newMCOption: FillBlanksMCOption = {
|
||||
uuid: uuidv4(),
|
||||
id: blank.id.toString(),
|
||||
options: {
|
||||
A: 'Option A',
|
||||
@@ -249,6 +255,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
@@ -98,6 +99,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
sentences: [
|
||||
...local.sentences,
|
||||
{
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
sentence: "",
|
||||
solution: ""
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import Alert, { AlertItem } from "../../Shared/Alert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
||||
@@ -57,6 +58,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
||||
{
|
||||
prompt: "",
|
||||
solution: "",
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
options,
|
||||
variant: "text"
|
||||
|
||||
@@ -18,6 +18,7 @@ import SortableQuestion from '../../Shared/SortableQuestion';
|
||||
import setEditingAlert from '../../Shared/setEditingAlert';
|
||||
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
interface MultipleChoiceProps {
|
||||
exercise: MultipleChoiceExercise;
|
||||
@@ -120,6 +121,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
||||
{
|
||||
prompt: "",
|
||||
solution: "",
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
options,
|
||||
variant: "text"
|
||||
|
||||
@@ -16,6 +16,7 @@ import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
@@ -50,6 +51,7 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
||||
{
|
||||
prompt: "",
|
||||
solution: undefined,
|
||||
uuid: uuidv4(),
|
||||
id: newId
|
||||
}
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ import { validateEmptySolutions, validateQuestionText, validateWordCount } from
|
||||
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
|
||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
|
||||
@@ -105,6 +106,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
||||
const newId = (Math.max(...existingIds, 0) + 1).toString();
|
||||
|
||||
const newQuestion = {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
questionText: "New question"
|
||||
};
|
||||
@@ -113,6 +115,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
||||
const updatedText = reconstructText(updatedQuestions);
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
solution: [""]
|
||||
}];
|
||||
|
||||
@@ -17,6 +17,7 @@ import { validateQuestions, validateEmptySolutions, validateWordCount } from "./
|
||||
import Header from "../../Shared/Header";
|
||||
import BlanksFormEditor from "./BlanksFormEditor";
|
||||
import PromptEdit from "../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||
@@ -111,6 +112,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
||||
|
||||
const newLine = `New question with blank {{${newId}}}`;
|
||||
const updatedQuestions = [...parsedQuestions, {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
parts: parseLine(newLine),
|
||||
editingPlaceholders: true
|
||||
@@ -121,6 +123,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
||||
.join('\\n') + '\\n';
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
solution: [""]
|
||||
}];
|
||||
|
||||
@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||
>
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import ExercisePicker from "../../ExercisePicker";
|
||||
import SettingsEditor from "..";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { useCallback, useState } from "react";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||
import { ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||
import Option from "@/interfaces/option";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../../Hooks/useSettingsState";
|
||||
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
||||
import Input from "@/components/Low/Input";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
@@ -17,7 +11,6 @@ import { usePersistentExamStore } from "@/stores/exam";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import ListeningComponents from "./components";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
|
||||
const ListeningSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -82,7 +82,7 @@ const ReadingComponents: React.FC<Props> = ({
|
||||
disabled={generatePassageDisabled}
|
||||
>
|
||||
<div
|
||||
className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4 "
|
||||
className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4 "
|
||||
>
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
|
||||
@@ -12,7 +12,6 @@ import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import ReadingComponents from "./components";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
|
||||
const ReadingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import clsx from "clsx";
|
||||
import SectionRenderer from "./SectionRenderer";
|
||||
import Checkbox from "../Low/Checkbox";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
import { capitalize } from "lodash";
|
||||
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
||||
import { Module } from "@/interfaces";
|
||||
@@ -21,13 +20,36 @@ import Button from "../Low/Button";
|
||||
import ResetModule from "./Standalone/ResetModule";
|
||||
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import Option from "../../interfaces/option";
|
||||
|
||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
const DIFFICULTIES: Option[] = [
|
||||
{ value: "A1", label: "A1" },
|
||||
{ value: "A2", label: "A2" },
|
||||
{ value: "B1", label: "B1" },
|
||||
{ value: "B2", label: "B2" },
|
||||
{ value: "C1", label: "C1" },
|
||||
{ value: "C2", label: "C2" },
|
||||
];
|
||||
|
||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings,
|
||||
};
|
||||
|
||||
const ExamEditor: React.FC<{
|
||||
levelParts?: number;
|
||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => {
|
||||
entitiesAllowConfExams: EntityWithRoles[];
|
||||
entitiesAllowPublicExams: EntityWithRoles[];
|
||||
}> = ({
|
||||
levelParts = 0,
|
||||
entitiesAllowEditPrivacy = [],
|
||||
entitiesAllowConfExams = [],
|
||||
entitiesAllowPublicExams = [],
|
||||
}) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
sections,
|
||||
@@ -111,7 +133,10 @@ const ExamEditor: React.FC<{
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numberOfLevelParts]);
|
||||
|
||||
const sectionIds = sections.map((section) => section.sectionId);
|
||||
const sectionIds = useMemo(
|
||||
() => sections.map((section) => section.sectionId),
|
||||
[sections]
|
||||
);
|
||||
|
||||
const updateModule = useCallback(
|
||||
(updates: Partial<ModuleState>) => {
|
||||
@@ -120,29 +145,42 @@ const ExamEditor: React.FC<{
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const toggleSection = (sectionId: number) => {
|
||||
const toggleSection = useCallback(
|
||||
(sectionId: number) => {
|
||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||
toast.error("Include at least one section!");
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
||||
};
|
||||
},
|
||||
[dispatch, expandedSections, sectionIds]
|
||||
);
|
||||
|
||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings,
|
||||
};
|
||||
const Settings = useMemo(
|
||||
() => ModuleSettings[currentModule],
|
||||
[currentModule]
|
||||
);
|
||||
|
||||
const Settings = ModuleSettings[currentModule];
|
||||
const showImport =
|
||||
importModule && ["reading", "listening", "level"].includes(currentModule);
|
||||
const showImport = useMemo(
|
||||
() =>
|
||||
importModule && ["reading", "listening", "level"].includes(currentModule),
|
||||
[importModule, currentModule]
|
||||
);
|
||||
|
||||
const updateLevelParts = (parts: number) => {
|
||||
const accessTypeOptions = useMemo(() => {
|
||||
let options: Option[] = [{ value: "private", label: "Private" }];
|
||||
if (entitiesAllowConfExams.length > 0) {
|
||||
options.push({ value: "confidential", label: "Confidential" });
|
||||
}
|
||||
if (entitiesAllowPublicExams.length > 0) {
|
||||
options.push({ value: "public", label: "Public" });
|
||||
}
|
||||
return options;
|
||||
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
|
||||
|
||||
const updateLevelParts = useCallback((parts: number) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -161,8 +199,13 @@ const ExamEditor: React.FC<{
|
||||
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-4 w-full items-center -xl:flex-col">
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 w-full",
|
||||
sectionLabels.length > 3 ? "-2xl:flex-col" : "-xl:flex-col"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="flex flex-col gap-3 ">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Timer
|
||||
@@ -176,19 +219,16 @@ const ExamEditor: React.FC<{
|
||||
})
|
||||
}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
className="max-w-[125px] min-w-[100px] w-min"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<div className="flex flex-col gap-3 ">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Difficulty
|
||||
</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES.map((x) => ({
|
||||
value: x,
|
||||
label: capitalize(x),
|
||||
}))}
|
||||
options={DIFFICULTIES}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values
|
||||
? values.map((v) => v.value as Difficulty)
|
||||
@@ -214,12 +254,12 @@ const ExamEditor: React.FC<{
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{sectionLabels[0].label.split(" ")[0]}
|
||||
</label>
|
||||
<div className="flex flex-row gap-8">
|
||||
<div className="flex flex-row gap-3">
|
||||
{sectionLabels.map(({ id, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
sectionIds.includes(id)
|
||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||
@@ -246,14 +286,14 @@ const ExamEditor: React.FC<{
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-64">
|
||||
<div className="max-w-[200px] w-full">
|
||||
<Select
|
||||
label="Access Type"
|
||||
options={ACCESSTYPE.map((item) => ({
|
||||
value: item,
|
||||
label: capitalize(item),
|
||||
}))}
|
||||
disabled={
|
||||
accessTypeOptions.length === 0 ||
|
||||
entitiesAllowEditPrivacy.length === 0
|
||||
}
|
||||
options={accessTypeOptions}
|
||||
onChange={(value) => {
|
||||
if (value?.value) {
|
||||
updateModule({ access: value.value! as AccessType });
|
||||
@@ -262,6 +302,8 @@ const ExamEditor: React.FC<{
|
||||
value={{ value: access, label: capitalize(access) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
@@ -286,7 +328,7 @@ const ExamEditor: React.FC<{
|
||||
Reset Module
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-8 -2xl:flex-col">
|
||||
<div className="flex flex-row gap-8 -xl:flex-col">
|
||||
<Settings />
|
||||
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||
<SectionRenderer />
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {useListSearch} from "@/hooks/useListSearch";
|
||||
import usePagination from "@/hooks/usePagination";
|
||||
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import {useMemo, useState} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
const SIZE = 25;
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import { checkAccess } from "@/utils/permissions";
|
||||
import Select from "../Low/Select";
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useRecordStore from "@/stores/recordStore";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { mapBy } from "@/utils";
|
||||
@@ -44,13 +42,13 @@ const RecordFilter: React.FC<Props> = ({
|
||||
|
||||
const [entity, setEntity] = useState<string>();
|
||||
|
||||
const [, setStatsUserId] = useRecordStore((state) => [
|
||||
const [selectedUser, setStatsUserId] = useRecordStore((state) => [
|
||||
state.selectedUser,
|
||||
state.setSelectedUser,
|
||||
]);
|
||||
|
||||
const entitiesToSearch = useMemo(() => {
|
||||
if(entity) return entity
|
||||
if (entity) return entity;
|
||||
if (isAdmin) return undefined;
|
||||
return mapBy(entities, "id");
|
||||
}, [entities, entity, isAdmin]);
|
||||
@@ -69,6 +67,14 @@ const RecordFilter: React.FC<Props> = ({
|
||||
"view_student_record"
|
||||
);
|
||||
|
||||
const selectedUserValue = useMemo(
|
||||
() =>
|
||||
users.find((u) => u.id === selectedUser) || {
|
||||
value: user.id,
|
||||
label: `${user.name} - ${user.email}`,
|
||||
},
|
||||
[selectedUser, user, users]
|
||||
);
|
||||
|
||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
||||
|
||||
@@ -118,10 +124,7 @@ const RecordFilter: React.FC<Props> = ({
|
||||
loadOptions={loadOptions}
|
||||
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
||||
options={users}
|
||||
defaultValue={{
|
||||
value: user.id,
|
||||
label: `${user.name} - ${user.email}`,
|
||||
}}
|
||||
defaultValue={selectedUserValue}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useRouter } from "next/router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { Exam, UserSolution } from "@/interfaces/exam";
|
||||
import ModuleBadge from "../ModuleBadge";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { findBy } from "@/utils";
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
BsPeople,
|
||||
BsChevronDown,
|
||||
BsChevronUp,
|
||||
BsChatText,
|
||||
BsCardText,
|
||||
} from "react-icons/bs";
|
||||
import { GoWorkflow } from "react-icons/go";
|
||||
import { CiDumbbell } from "react-icons/ci";
|
||||
@@ -31,7 +35,7 @@ import {
|
||||
useAllowedEntities,
|
||||
useAllowedEntitiesSomePermissions,
|
||||
} from "@/hooks/useEntityPermissions";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { PermissionType } from "../interfaces/permissions";
|
||||
|
||||
interface Props {
|
||||
@@ -52,6 +56,7 @@ interface NavProps {
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
badge?: number;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Nav = ({
|
||||
@@ -62,8 +67,16 @@ const Nav = ({
|
||||
disabled = false,
|
||||
isMinimized = false,
|
||||
badge,
|
||||
children,
|
||||
}: NavProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-2 transition-all duration-300 ease-in-out",
|
||||
open && !isMinimized && "bg-white rounded-xl"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
@@ -89,7 +102,36 @@ const Nav = ({
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
{children && (
|
||||
<button
|
||||
className="flex items-center gap-4 rounded-full p-4 absolute right-0"
|
||||
onClick={(e) => {
|
||||
setOpen((prev) => !prev);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<BsChevronUp
|
||||
size={24}
|
||||
className={clsx(
|
||||
isMinimized && "hidden",
|
||||
"transition ease-in-out duration-300"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<BsChevronDown
|
||||
size={24}
|
||||
className={clsx(
|
||||
isMinimized && "hidden",
|
||||
"transition ease-in-out duration-300"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
{open || isMinimized ? children : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,12 +163,12 @@ export default function Sidebar({
|
||||
entities,
|
||||
"view_statistics"
|
||||
);
|
||||
|
||||
const entitiesAllowPaymentRecord = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_payment_record"
|
||||
);
|
||||
|
||||
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
|
||||
user,
|
||||
entities,
|
||||
@@ -148,7 +190,7 @@ export default function Sidebar({
|
||||
viewTickets: true,
|
||||
viewClassrooms: true,
|
||||
viewSettings: true,
|
||||
viewPaymentRecord: true,
|
||||
viewPaymentRecords: true,
|
||||
viewGeneration: true,
|
||||
viewApprovalWorkflows: true,
|
||||
};
|
||||
@@ -160,7 +202,7 @@ export default function Sidebar({
|
||||
viewTickets: false,
|
||||
viewClassrooms: false,
|
||||
viewSettings: false,
|
||||
viewPaymentRecord: false,
|
||||
viewPaymentRecords: false,
|
||||
viewGeneration: false,
|
||||
viewApprovalWorkflows: false,
|
||||
};
|
||||
@@ -235,7 +277,7 @@ export default function Sidebar({
|
||||
) &&
|
||||
entitiesAllowPaymentRecord.length > 0
|
||||
) {
|
||||
sidebarPermissions["viewPaymentRecord"] = true;
|
||||
sidebarPermissions["viewPaymentRecords"] = true;
|
||||
}
|
||||
return sidebarPermissions;
|
||||
}, [
|
||||
@@ -325,7 +367,24 @@ export default function Sidebar({
|
||||
path={path}
|
||||
keyPath="/training"
|
||||
isMinimized={isMinimized}
|
||||
>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsChatText}
|
||||
label="Vocabulary"
|
||||
path={path}
|
||||
keyPath="/training/vocabulary"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCardText}
|
||||
label="Grammar"
|
||||
path={path}
|
||||
keyPath="/training/grammar"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</Nav>
|
||||
)}
|
||||
{sidebarPermissions["viewPaymentRecords"] && (
|
||||
<Nav
|
||||
@@ -378,7 +437,6 @@ export default function Sidebar({
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav
|
||||
@@ -425,6 +483,33 @@ export default function Sidebar({
|
||||
path={path}
|
||||
keyPath="/training"
|
||||
isMinimized
|
||||
>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsChatText}
|
||||
label="Vocabulary"
|
||||
path={path}
|
||||
keyPath="/training/vocabulary"
|
||||
isMinimized
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCardText}
|
||||
label="Grammar"
|
||||
path={path}
|
||||
keyPath="/training/grammar"
|
||||
isMinimized
|
||||
/>
|
||||
</Nav>
|
||||
)}
|
||||
{sidebarPermissions["viewPaymentRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewSettings"] && (
|
||||
@@ -483,7 +568,7 @@ export default function Sidebar({
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out -xl:px-4",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -38,10 +38,7 @@ export default function usePagination<T>(list: T[], size = 25) {
|
||||
<Select
|
||||
value={{
|
||||
value: itemsPerPage.toString(),
|
||||
label: (itemsPerPage * page > items.length
|
||||
? items.length
|
||||
: itemsPerPage * page
|
||||
).toString(),
|
||||
label: itemsPerPage.toString(),
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setItemsPerPage(parseInt(value!.value ?? "25"))
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ExamBase {
|
||||
access: AccessType;
|
||||
label?: string;
|
||||
requiresApproval?: boolean;
|
||||
approved?: boolean;
|
||||
}
|
||||
export interface ReadingExam extends ExamBase {
|
||||
module: "reading";
|
||||
@@ -241,6 +242,7 @@ export interface InteractiveSpeakingExercise extends Section {
|
||||
}
|
||||
|
||||
export interface FillBlanksMCOption {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string;
|
||||
options: {
|
||||
A: string;
|
||||
@@ -258,6 +260,7 @@ export interface FillBlanksExercise {
|
||||
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
||||
allowRepetition?: boolean;
|
||||
solutions: {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "1"
|
||||
solution: string; // *EXAMPLE: "preserve"
|
||||
}[];
|
||||
@@ -281,6 +284,7 @@ export interface TrueFalseExercise {
|
||||
}
|
||||
|
||||
export interface TrueFalseQuestion {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "1"
|
||||
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||
solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True"
|
||||
@@ -293,6 +297,7 @@ export interface WriteBlanksExercise {
|
||||
id: string;
|
||||
text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
|
||||
solutions: {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "14"
|
||||
solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive)
|
||||
}[];
|
||||
@@ -319,12 +324,14 @@ export interface MatchSentencesExercise {
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseSentence {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string;
|
||||
sentence: string;
|
||||
solution: string;
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseOption {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string;
|
||||
sentence: string;
|
||||
}
|
||||
@@ -346,6 +353,7 @@ export interface MultipleChoiceExercise {
|
||||
|
||||
export interface MultipleChoiceQuestion {
|
||||
variant: "image" | "text";
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "1"
|
||||
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||
solution: string; // *EXAMPLE: "A"
|
||||
|
||||
@@ -68,14 +68,14 @@ export async function createApprovalWorkflowOnExamCreation(examAuthor: string, e
|
||||
}
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
|
||||
// commented because they asked for every exam to stay confidential
|
||||
/* if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
|
||||
await db.collection(examModule).updateOne(
|
||||
{ id: examId },
|
||||
{ $set: { id: examId, isDiagnostic: false }},
|
||||
{ $set: { id: examId, access: "private" }},
|
||||
{ upsert: true }
|
||||
);
|
||||
}
|
||||
} */
|
||||
|
||||
return {
|
||||
successCount,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
@@ -15,19 +13,21 @@ import ShortUniqueId from "short-unique-id";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import Modal from "@/components/Modal";
|
||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import Select from "@/components/Low/Select";
|
||||
import CodeGenImportSummary, { ExcelCodegenDuplicatesMap } from "@/components/ImportSummaries/Codegen";
|
||||
import CodeGenImportSummary, {
|
||||
ExcelCodegenDuplicatesMap,
|
||||
} from "@/components/ImportSummaries/Codegen";
|
||||
import { FaFileDownload } from "react-icons/fa";
|
||||
import { IoInformationCircleOutline } from "react-icons/io5";
|
||||
import { HiOutlineDocumentText } from "react-icons/hi";
|
||||
import CodegenTable from "@/components/Tables/CodeGenTable";
|
||||
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
const EMAIL_REGEX = new RegExp(
|
||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||
);
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
@@ -54,11 +54,26 @@ const USER_TYPE_PERMISSIONS: {
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -66,22 +81,38 @@ interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[]
|
||||
entities: EntityWithRoles[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) {
|
||||
const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]);
|
||||
export default function BatchCodeGenerator({
|
||||
user,
|
||||
users,
|
||||
entities = [],
|
||||
permissions,
|
||||
onFinish,
|
||||
}: Props) {
|
||||
const [infos, setInfos] = useState<
|
||||
{ email: string; name: string; passport_id: string }[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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 [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined });
|
||||
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>();
|
||||
const [parsedExcel, setParsedExcel] = useState<{
|
||||
rows?: any[];
|
||||
errors?: any[];
|
||||
}>({ rows: undefined, errors: undefined });
|
||||
const [duplicatedRows, setDuplicatedRows] = useState<{
|
||||
duplicates: ExcelCodegenDuplicatesMap;
|
||||
count: number;
|
||||
}>();
|
||||
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
@@ -94,62 +125,62 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const schema = {
|
||||
'First Name': {
|
||||
prop: 'firstName',
|
||||
"First Name": {
|
||||
prop: "firstName",
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('First Name cannot be empty')
|
||||
}
|
||||
return true
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("First Name cannot be empty");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
'Last Name': {
|
||||
prop: 'lastName',
|
||||
},
|
||||
"Last Name": {
|
||||
prop: "lastName",
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('Last Name cannot be empty')
|
||||
}
|
||||
return true
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("Last Name cannot be empty");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
'Passport/National ID': {
|
||||
prop: 'passport_id',
|
||||
},
|
||||
"Passport/National ID": {
|
||||
prop: "passport_id",
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('Passport/National ID cannot be empty')
|
||||
}
|
||||
return true
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("Passport/National ID cannot be empty");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
'E-mail': {
|
||||
prop: 'email',
|
||||
},
|
||||
"E-mail": {
|
||||
prop: "email",
|
||||
required: true,
|
||||
type: (value: any) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('Email cannot be empty')
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("Email cannot be empty");
|
||||
}
|
||||
if (!EMAIL_REGEX.test(value.trim())) {
|
||||
throw new Error('Invalid Email')
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid Email");
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(
|
||||
file.content, { schema, ignoreEmptyRows: false })
|
||||
.then((data) => {
|
||||
setParsedExcel(data)
|
||||
});
|
||||
readXlsxFile(file.content, { schema, ignoreEmptyRows: false }).then(
|
||||
(data) => {
|
||||
setParsedExcel(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
@@ -164,12 +195,14 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
const duplicateRowIndices = new Set<number>();
|
||||
|
||||
const errorRowIndices = new Set(
|
||||
parsedExcel.errors?.map(error => error.row) || []
|
||||
parsedExcel.errors?.map((error) => error.row) || []
|
||||
);
|
||||
|
||||
parsedExcel.rows.forEach((row, index) => {
|
||||
if (!errorRowIndices.has(index + 2)) {
|
||||
(Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).forEach(field => {
|
||||
(
|
||||
Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
|
||||
).forEach((field) => {
|
||||
if (row !== null) {
|
||||
const value = row[field];
|
||||
if (value) {
|
||||
@@ -180,7 +213,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
if (existingRows) {
|
||||
existingRows.push(index + 2);
|
||||
duplicateValues.add(value);
|
||||
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
|
||||
existingRows.forEach((rowNum) =>
|
||||
duplicateRowIndices.add(rowNum)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,10 +226,23 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
|
||||
const info = parsedExcel.rows
|
||||
.map((row, index) => {
|
||||
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
|
||||
if (
|
||||
errorRowIndices.has(index + 2) ||
|
||||
duplicateRowIndices.has(index + 2) ||
|
||||
row === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
studentID,
|
||||
passport_id,
|
||||
email,
|
||||
phone,
|
||||
group,
|
||||
country,
|
||||
} = row;
|
||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -204,31 +252,49 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
};
|
||||
}).filter((x) => !!x) as typeof infos;
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos;
|
||||
|
||||
setInfos(info);
|
||||
}
|
||||
}, [entity, parsedExcel, type]);
|
||||
|
||||
const generateAndInvite = async () => {
|
||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||
const newUsers = infos.filter(
|
||||
(x) => !users.map((u) => u.email).includes(x.email)
|
||||
);
|
||||
const existingUsers = infos
|
||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||
.map((i) => users.find((u) => u.email === i.email))
|
||||
.filter((x) => !!x && x.type === "student") as User[];
|
||||
|
||||
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
||||
const newUsersSentence =
|
||||
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence =
|
||||
existingUsers.length > 0
|
||||
? `invite ${existingUsers.length} registered student(s)`
|
||||
: undefined;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence]
|
||||
.filter((x) => !!x)
|
||||
.join(" and ")}, are you sure you want to continue?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id })))
|
||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
||||
Promise.all(
|
||||
existingUsers.map(
|
||||
async (u) =>
|
||||
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||
)
|
||||
)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||
)
|
||||
)
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
@@ -246,17 +312,20 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations.map((info, index) => ({ ...info, code: codes[index] })),
|
||||
infos: informations.map((info, index) => ({
|
||||
...info,
|
||||
code: codes[index],
|
||||
})),
|
||||
expiryDate,
|
||||
entity
|
||||
entity,
|
||||
})
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(
|
||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||
type,
|
||||
)} codes and they have been notified by e-mail!`,
|
||||
{ toastId: "success" },
|
||||
`Successfully generated${
|
||||
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
||||
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
||||
{ toastId: "success" }
|
||||
);
|
||||
|
||||
onFinish();
|
||||
@@ -287,7 +356,7 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
const fileName = "BatchCodeTemplate.xlsx";
|
||||
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
link.download = fileName;
|
||||
@@ -301,11 +370,15 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
<>
|
||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
||||
<>
|
||||
<div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div>
|
||||
<div className="flex font-bold text-xl justify-center text-gray-700">
|
||||
<span>Excel File Format</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} />
|
||||
<HiOutlineDocumentText
|
||||
className={`w-5 h-5 text-mti-purple-light`}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">
|
||||
The uploaded document must:
|
||||
</h2>
|
||||
@@ -315,15 +388,24 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
be an Excel .xlsx document.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
only have a single spreadsheet with the following <b>exact same name</b> columns:
|
||||
only have a single spreadsheet with the following{" "}
|
||||
<b>exact same name</b> columns:
|
||||
<div className="py-4 pr-4">
|
||||
<table className="w-full bg-white">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
First Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Last Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Passport/National ID
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
E-mail
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
@@ -333,10 +415,10 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} />
|
||||
<h2 className="text-lg font-semibold">
|
||||
Note that:
|
||||
</h2>
|
||||
<IoInformationCircleOutline
|
||||
className={`w-5 h-5 text-mti-purple-light`}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">Note that:</h2>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
@@ -346,10 +428,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
all already registered e-mails will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all rows which contain duplicate values in the columns: "Passport/National ID", "E-mail", will be ignored.
|
||||
all rows which contain duplicate values in the columns:
|
||||
"Passport/National ID", "E-mail", will be
|
||||
ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
|
||||
all of the e-mails in the file will receive an e-mail to join
|
||||
EnCoach with the role selected below.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -359,11 +444,21 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full flex justify-between mt-6 gap-8">
|
||||
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white">
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => setShowHelp(false)}
|
||||
variant="outline"
|
||||
className="self-end w-full bg-white"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={handleTemplateDownload}
|
||||
variant="solid"
|
||||
className="self-end w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFileDownload size={24} />
|
||||
Download Template
|
||||
@@ -375,7 +470,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
</Modal>
|
||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-end justify-between">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Choose an Excel file
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||
@@ -384,14 +481,30 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
<IoInformationCircleOutline size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
<Button
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||
{user &&
|
||||
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">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
@@ -400,11 +513,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
@@ -414,41 +529,67 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
||||
</>
|
||||
)}
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
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 && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => {
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS).reduce((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||
})
|
||||
.map((type) => (
|
||||
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||
acc.push(
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
return acc;
|
||||
}, [] as JSX.Element[])}
|
||||
</select>
|
||||
)}
|
||||
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>}
|
||||
{infos.length > 0 && (
|
||||
<CodeGenImportSummary
|
||||
infos={infos}
|
||||
parsedExcel={parsedExcel}
|
||||
duplicateRows={duplicatedRows}
|
||||
/>
|
||||
)}
|
||||
{infos.length !== 0 && (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span>
|
||||
<span className="text-mti-gray-dim text-base font-normal">
|
||||
Codes will be sent to:
|
||||
</span>
|
||||
<CodegenTable infos={infos} />
|
||||
</div>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
{checkAccess(
|
||||
user,
|
||||
["developer", "admin", "corporate", "mastercorporate"],
|
||||
permissions,
|
||||
"createCodes"
|
||||
) && (
|
||||
<Button
|
||||
onClick={generateAndInvite}
|
||||
disabled={
|
||||
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||
}
|
||||
>
|
||||
Generate & Send
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
@@ -13,10 +12,8 @@ import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
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: {
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
@@ -43,30 +40,52 @@ const USER_TYPE_PERMISSIONS: {
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[]
|
||||
entities: EntityWithRoles[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) {
|
||||
export default function CodeGenerator({
|
||||
user,
|
||||
entities = [],
|
||||
permissions,
|
||||
onFinish,
|
||||
}: Props) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
|
||||
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 [type, setType] = useState<Type>("student");
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
@@ -105,11 +124,18 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
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"])}
|
||||
@@ -121,25 +147,33 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => {
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||
})
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||
acc.push(x);
|
||||
return acc;
|
||||
}, [])}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
@@ -148,11 +182,13 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
@@ -161,25 +197,40 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
||||
{checkAccess(
|
||||
user,
|
||||
["developer", "admin", "corporate", "mastercorporate"],
|
||||
permissions,
|
||||
"createCodes"
|
||||
) && (
|
||||
<Button
|
||||
onClick={() => generateCode(type)}
|
||||
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Generated Code:
|
||||
</label>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
data-tip="Click to copy"
|
||||
onClick={() => {
|
||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{generatedCode}
|
||||
</div>
|
||||
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
||||
{generatedCode && (
|
||||
<span className="text-sm text-mti-gray-dim font-light">
|
||||
Give this code to the user to complete their registration
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import axios from "axios";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import axios from "axios";
|
||||
import { capitalize } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs";
|
||||
import { BsPencil, BsTrash, BsUpload } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { CorporateUser, Group, User } from "@/interfaces/user";
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { Group, User } from "@/interfaces/user";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import { capitalize, uniq } from "lodash";
|
||||
import { uniq } from "lodash";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||
import Select from "react-select";
|
||||
import { toast } from "react-toastify";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
import { getUserCorporate } from "@/utils/groups";
|
||||
import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import Table from "@/components/High/Table";
|
||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||
import { WithEntity } from "@/interfaces/entity";
|
||||
|
||||
const searchFields = [["name"]];
|
||||
|
||||
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
||||
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]+)*$/
|
||||
);
|
||||
|
||||
interface CreateDialogProps {
|
||||
user: User;
|
||||
@@ -35,9 +34,13 @@ interface CreateDialogProps {
|
||||
}
|
||||
|
||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||
const [name, setName] = useState<string | undefined>(
|
||||
group?.name || undefined
|
||||
);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||
const [participants, setParticipants] = useState<string[]>(
|
||||
group?.participants || []
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
@@ -47,9 +50,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
});
|
||||
|
||||
const availableUsers = useMemo(() => {
|
||||
if (user?.type === "teacher") return users.filter((x) => ["student"].includes(x.type));
|
||||
if (user?.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||
if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type));
|
||||
if (user?.type === "teacher")
|
||||
return users.filter((x) => ["student"].includes(x.type));
|
||||
if (user?.type === "corporate")
|
||||
return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||
if (user?.type === "mastercorporate")
|
||||
return users.filter((x) =>
|
||||
["corporate", "teacher", "student"].includes(x.type)
|
||||
);
|
||||
|
||||
return users;
|
||||
}, [user, users]);
|
||||
@@ -64,9 +72,12 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
rows
|
||||
.map((row) => {
|
||||
const [email] = row as string[];
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||
return EMAIL_REGEX.test(email) &&
|
||||
!users.map((u) => u.email).includes(email)
|
||||
? email.toString().trim()
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
.filter((x) => !!x)
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
@@ -76,12 +87,17 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||
const emailUsers = [...new Set(emails)]
|
||||
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||
.filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
|
||||
((user.type === "developer" ||
|
||||
user.type === "admin" ||
|
||||
user.type === "corporate" ||
|
||||
user.type === "mastercorporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
(user.type === "teacher" && x?.type === "student")
|
||||
);
|
||||
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
@@ -89,7 +105,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{ toastId: "upload-success" },
|
||||
{ toastId: "upload-success" }
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
@@ -100,15 +116,27 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
const submit = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
if (
|
||||
name !== group?.name &&
|
||||
(name?.trim() === "Students" ||
|
||||
name?.trim() === "Teachers" ||
|
||||
name?.trim() === "Corporate")
|
||||
) {
|
||||
toast.error(
|
||||
"That group name is reserved and cannot be used, please enter another one."
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants })
|
||||
(group ? axios.patch : axios.post)(
|
||||
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||
{ name, admin, participants }
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||
toast.success(
|
||||
`Group "${name}" ${group ? "edited" : "created"} successfully`
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -121,30 +149,58 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const userOptions = useMemo(
|
||||
() =>
|
||||
availableUsers.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.email} - ${x.name}`,
|
||||
})),
|
||||
[availableUsers]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() =>
|
||||
participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${
|
||||
users.find((y) => y.id === x)?.name
|
||||
}`,
|
||||
})),
|
||||
[participants, users]
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||
<Input
|
||||
name="name"
|
||||
type="text"
|
||||
label="Name"
|
||||
defaultValue={name}
|
||||
onChange={setName}
|
||||
required
|
||||
disabled={group?.disableEditing}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Participants
|
||||
</label>
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-8">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
value={value}
|
||||
placeholder="Participants..."
|
||||
defaultValue={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
options={availableUsers.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
||||
defaultValue={value}
|
||||
options={userOptions}
|
||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||
isMulti
|
||||
isSearchable
|
||||
@@ -160,18 +216,36 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
}}
|
||||
/>
|
||||
{user.type !== "teacher" && (
|
||||
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||
<Button
|
||||
className="w-full max-w-[300px] h-fit"
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{filesContent.length === 0
|
||||
? "Upload participants Excel file"
|
||||
: filesContent[0].name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
isLoading={isLoading}
|
||||
disabled={!name}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
@@ -182,7 +256,8 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
export default function GroupList({ user }: { user: User }) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
||||
const [viewingAllParticipants, setViewingAllParticipants] =
|
||||
useState<string>();
|
||||
|
||||
const { permissions } = usePermissions(user?.id || "");
|
||||
|
||||
@@ -211,7 +286,14 @@ export default function GroupList({ user }: { user: User }) {
|
||||
columnHelper.accessor("admin", {
|
||||
header: "Admin",
|
||||
cell: (info) => (
|
||||
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip={
|
||||
USER_TYPE_LABELS[
|
||||
users.find((x) => x.id === info.getValue())?.type || "student"
|
||||
]
|
||||
}
|
||||
>
|
||||
{users.find((x) => x.id === info.getValue())?.name}
|
||||
</div>
|
||||
),
|
||||
@@ -226,20 +308,27 @@ export default function GroupList({ user }: { user: User }) {
|
||||
<span>
|
||||
{info
|
||||
.getValue()
|
||||
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
||||
.slice(
|
||||
0,
|
||||
viewingAllParticipants === info.row.original.id ? undefined : 5
|
||||
)
|
||||
.map((x) => users.find((y) => y.id === x)?.name)
|
||||
.join(", ")}
|
||||
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
|
||||
{info.getValue().length > 5 &&
|
||||
viewingAllParticipants !== info.row.original.id && (
|
||||
<button
|
||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
onClick={() => setViewingAllParticipants(info.row.original.id)}>
|
||||
onClick={() => setViewingAllParticipants(info.row.original.id)}
|
||||
>
|
||||
, View More
|
||||
</button>
|
||||
)}
|
||||
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
|
||||
{info.getValue().length > 5 &&
|
||||
viewingAllParticipants === info.row.original.id && (
|
||||
<button
|
||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
onClick={() => setViewingAllParticipants(undefined)}>
|
||||
onClick={() => setViewingAllParticipants(undefined)}
|
||||
>
|
||||
, View Less
|
||||
</button>
|
||||
)}
|
||||
@@ -252,15 +341,29 @@ export default function GroupList({ user }: { user: User }) {
|
||||
cell: ({ row }: { row: { original: Group } }) => {
|
||||
return (
|
||||
<>
|
||||
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
||||
{user &&
|
||||
(checkAccess(user, ["developer", "admin"]) ||
|
||||
user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
|
||||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
||||
{(!row.original.disableEditing ||
|
||||
checkAccess(user, ["developer", "admin"]),
|
||||
"editGroup") && (
|
||||
<div
|
||||
data-tip="Edit"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => setEditingGroup(row.original)}
|
||||
>
|
||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
|
||||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
||||
{(!row.original.disableEditing ||
|
||||
checkAccess(user, ["developer", "admin"]),
|
||||
"deleteGroup") && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => deleteGroup(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
@@ -280,7 +383,11 @@ export default function GroupList({ user }: { user: User }) {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingGroup}
|
||||
onClose={closeModal}
|
||||
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||
>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
@@ -288,12 +395,22 @@ export default function GroupList({ user }: { user: User }) {
|
||||
users={users}
|
||||
/>
|
||||
</Modal>
|
||||
<Table data={groups} columns={defaultColumns} searchFields={searchFields} />
|
||||
<Table
|
||||
data={groups}
|
||||
columns={defaultColumns}
|
||||
searchFields={searchFields}
|
||||
/>
|
||||
|
||||
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
|
||||
{checkAccess(
|
||||
user,
|
||||
["teacher", "corporate", "mastercorporate", "admin", "developer"],
|
||||
permissions,
|
||||
"createGroup"
|
||||
) && (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||
>
|
||||
New Group
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -4,10 +4,15 @@ import usePackages from "@/hooks/usePackages";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Package } from "@/interfaces/paypal";
|
||||
import { User } from "@/interfaces/user";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import { capitalize } from "lodash";
|
||||
import {useState} from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import Select from "react-select";
|
||||
@@ -26,20 +31,36 @@ const columnHelper = createColumnHelper<Package>();
|
||||
|
||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||
|
||||
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
|
||||
const currencyOptions = CURRENCIES.map(({ label, currency }) => ({
|
||||
value: currency,
|
||||
label,
|
||||
}));
|
||||
|
||||
function PackageCreator({
|
||||
pack,
|
||||
onClose,
|
||||
}: {
|
||||
pack?: Package;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
||||
const [unit, setUnit] = useState<DurationUnit>(
|
||||
pack?.duration_unit || "months"
|
||||
);
|
||||
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||
|
||||
const submit = () => {
|
||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
||||
const submit = useCallback(() => {
|
||||
(pack ? axios.patch : axios.post)(
|
||||
pack ? `/api/packages/${pack.id}` : "/api/packages",
|
||||
{
|
||||
duration,
|
||||
duration_unit: unit,
|
||||
price,
|
||||
currency,
|
||||
})
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
toast.success("New payment has been created successfully!");
|
||||
onClose();
|
||||
@@ -47,21 +68,35 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
}, [duration, unit, price, currency, pack, onClose]);
|
||||
|
||||
const currencyDefaultValue = useMemo(() => {
|
||||
return {
|
||||
value: currency || "EUR",
|
||||
label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
|
||||
};
|
||||
}, [currency]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Price *
|
||||
</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
||||
<Input
|
||||
defaultValue={price}
|
||||
name="price"
|
||||
type="number"
|
||||
onChange={(e) => setPrice(parseInt(e))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
options={currencyOptions}
|
||||
defaultValue={currencyDefaultValue}
|
||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
value={currencyDefaultValue}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
@@ -76,7 +111,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
@@ -84,9 +123,16 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Duration *
|
||||
</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
|
||||
<Input
|
||||
defaultValue={duration}
|
||||
name="duration"
|
||||
type="number"
|
||||
onChange={(e) => setDuration(parseInt(e))}
|
||||
/>
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
@@ -96,7 +142,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
{ value: "years", label: "Years" },
|
||||
]}
|
||||
defaultValue={{ value: "months", label: "Months" }}
|
||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
||||
onChange={(value) =>
|
||||
setUnit((value?.value as DurationUnit) || "months")
|
||||
}
|
||||
value={{ value: unit, label: capitalize(unit) }}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
@@ -112,7 +160,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
@@ -120,10 +172,19 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
disabled={!duration || !price}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
@@ -137,7 +198,8 @@ export default function PackageList({user}: {user: User}) {
|
||||
|
||||
const { packages, reload } = usePackages();
|
||||
|
||||
const deletePackage = async (pack: Package) => {
|
||||
const deletePackage = useCallback(
|
||||
async (pack: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||
|
||||
axios
|
||||
@@ -157,9 +219,12 @@ export default function PackageList({user}: {user: User}) {
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
const defaultColumns = [
|
||||
const defaultColumns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
@@ -186,13 +251,21 @@ export default function PackageList({user}: {user: User}) {
|
||||
cell: ({ row }: { row: { original: Package } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
|
||||
{["developer", "admin"].includes(user?.type) && (
|
||||
<div
|
||||
data-tip="Edit"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => setEditingPackage(row.original)}
|
||||
>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
|
||||
{["developer", "admin"].includes(user?.type) && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deletePackage(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
@@ -200,7 +273,9 @@ export default function PackageList({user}: {user: User}) {
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
],
|
||||
[deletePackage, user]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: packages,
|
||||
@@ -208,18 +283,19 @@ export default function PackageList({user}: {user: User}) {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
const closeModal = useCallback(() => {
|
||||
setIsCreating(false);
|
||||
setEditingPackage(undefined);
|
||||
reload();
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingPackage}
|
||||
onClose={closeModal}
|
||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
|
||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
|
||||
>
|
||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||
@@ -228,7 +304,12 @@ export default function PackageList({user}: {user: User}) {
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -236,7 +317,10 @@ export default function PackageList({user}: {user: User}) {
|
||||
</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}>
|
||||
<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())}
|
||||
@@ -248,7 +332,8 @@ export default function PackageList({user}: {user: User}) {
|
||||
</table>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
|
||||
>
|
||||
New Package
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,19 @@ import {averageLevelCalculator} from "@/utils/score";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import List from "@/components/List";
|
||||
import Table from "@/components/High/Table";
|
||||
|
||||
type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string};
|
||||
type StudentPerformanceItem = StudentUser & {
|
||||
entitiesLabel: string;
|
||||
group: string;
|
||||
userStats: Stat[];
|
||||
};
|
||||
|
||||
const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceItem[]; stats: Stat[]}) => {
|
||||
const StudentPerformanceList = ({
|
||||
items = [],
|
||||
}: {
|
||||
items: StudentPerformanceItem[];
|
||||
}) => {
|
||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||
|
||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||
@@ -41,46 +48,86 @@ const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceI
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "reading"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.listening", {
|
||||
header: "Listening",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "listening"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.writing", {
|
||||
header: "Writing",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "writing"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.speaking", {
|
||||
header: "Speaking",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "speaking"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.level", {
|
||||
header: "Level",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "level"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels", {
|
||||
columnHelper.accessor("userStats", {
|
||||
id: "overall_level",
|
||||
header: "Overall",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? averageLevelCalculator(
|
||||
items,
|
||||
stats.filter((x) => x.user === info.row.original.id),
|
||||
info.row.original.focus,
|
||||
info.getValue()
|
||||
).toFixed(1)
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||
: `${Object.keys(groupByExam(info.getValue())).length} exams`,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -92,17 +139,17 @@ const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceI
|
||||
<Table<StudentPerformanceItem>
|
||||
data={items.sort(
|
||||
(a, b) =>
|
||||
averageLevelCalculator(
|
||||
items,
|
||||
stats.filter((x) => x.user === b.id),
|
||||
) -
|
||||
averageLevelCalculator(
|
||||
items,
|
||||
stats.filter((x) => x.user === a.id),
|
||||
),
|
||||
averageLevelCalculator(b.focus, b.userStats) -
|
||||
averageLevelCalculator(a.focus, a.userStats)
|
||||
)}
|
||||
columns={columns}
|
||||
searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]}
|
||||
searchFields={[
|
||||
["name"],
|
||||
["email"],
|
||||
["studentID"],
|
||||
["entitiesLabel"],
|
||||
["group"],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { capitalize } from "lodash";
|
||||
import moment from "moment";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
BsCheck,
|
||||
BsCheckCircle,
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import moment from "moment";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import Input from "@/components/Low/Input";
|
||||
import CountrySelect from "@/components/Low/CountrySelect";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {getUserName} from "@/utils/users";
|
||||
import Select from "@/components/Low/Select";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||
import {mapBy} from "@/utils";
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
@@ -49,11 +41,26 @@ const USER_TYPE_PERMISSIONS: {
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,7 +72,13 @@ interface Props {
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function UserCreator({user, users, entities = [], permissions, onFinish}: Props) {
|
||||
export default function UserCreator({
|
||||
user,
|
||||
users,
|
||||
entities = [],
|
||||
permissions,
|
||||
onFinish,
|
||||
}: Props) {
|
||||
const [name, setName] = useState<string>();
|
||||
const [email, setEmail] = useState<string>();
|
||||
const [phone, setPhone] = useState<string>();
|
||||
@@ -76,7 +89,9 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
const [password, setPassword] = useState<string>();
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>();
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
@@ -91,11 +106,16 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const createUser = () => {
|
||||
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
|
||||
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
|
||||
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
|
||||
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
|
||||
if (password !== confirmPassword) return toast.error("The passwords do not match!");
|
||||
if (!name || name.trim().length === 0)
|
||||
return toast.error("Please enter a valid name!");
|
||||
if (!email || email.trim().length === 0)
|
||||
return toast.error("Please enter a valid e-mail address!");
|
||||
if (users.map((x) => x.email).includes(email.trim()))
|
||||
return toast.error("That e-mail is already in use!");
|
||||
if (!password || password.trim().length < 6)
|
||||
return toast.error("Please enter a valid password!");
|
||||
if (password !== confirmPassword)
|
||||
return toast.error("The passwords do not match!");
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -130,7 +150,11 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
setCountry(user?.demographicInformation?.country);
|
||||
setGroup(null);
|
||||
setEntity((entities || [])[0]?.id || undefined);
|
||||
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
|
||||
setExpiryDate(
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user?.subscriptionExpirationDate).toDate()
|
||||
: null
|
||||
);
|
||||
setIsExpiryDateEnabled(true);
|
||||
setType("student");
|
||||
setPosition(undefined);
|
||||
@@ -146,10 +170,34 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
|
||||
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
|
||||
<Input
|
||||
required
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<Input
|
||||
label="E-mail"
|
||||
required
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="E-mail"
|
||||
/>
|
||||
|
||||
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
@@ -161,11 +209,21 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Country *
|
||||
</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</div>
|
||||
|
||||
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
label="Phone number"
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
placeholder="Phone number"
|
||||
required
|
||||
/>
|
||||
|
||||
{type === "student" && (
|
||||
<>
|
||||
@@ -178,14 +236,26 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
placeholder="National ID or Passport number"
|
||||
required
|
||||
/>
|
||||
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
|
||||
<Input
|
||||
type="text"
|
||||
name="studentID"
|
||||
label="Student ID"
|
||||
onChange={setStudentID}
|
||||
value={studentID}
|
||||
placeholder="Student ID"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{value: (entities || [])[0]?.id, label: (entities || [])[0]?.label}}
|
||||
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"])}
|
||||
@@ -193,13 +263,24 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
</div>
|
||||
|
||||
{["corporate", "mastercorporate"].includes(type) && (
|
||||
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
||||
<Input
|
||||
type="text"
|
||||
name="department"
|
||||
label="Department"
|
||||
onChange={setPosition}
|
||||
value={position}
|
||||
placeholder="Department"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Classroom
|
||||
</label>
|
||||
<Select
|
||||
options={groups.filter((x) => x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))}
|
||||
options={groups
|
||||
.filter((x) => x.entity?.id === entity)
|
||||
.map((g) => ({ value: g.id, label: g.name }))}
|
||||
onChange={(e) => setGroup(e?.value || undefined)}
|
||||
isClearable
|
||||
/>
|
||||
@@ -208,38 +289,52 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-4",
|
||||
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
|
||||
)}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
!checkAccess(user, [
|
||||
"developer",
|
||||
"admin",
|
||||
"corporate",
|
||||
"mastercorporate",
|
||||
]) && "col-span-2"
|
||||
)}
|
||||
>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Type
|
||||
</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as Type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => {
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||
})
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||
acc.push(x);
|
||||
return acc;
|
||||
}, [])}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||
{user &&
|
||||
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">
|
||||
<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>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user?.subscriptionExpirationDate}>
|
||||
disabled={!!user?.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
@@ -248,11 +343,15 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
|
||||
(user?.subscriptionExpirationDate
|
||||
? moment(date).isBefore(
|
||||
user?.subscriptionExpirationDate
|
||||
)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
@@ -264,7 +363,11 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
|
||||
<Button
|
||||
onClick={createUser}
|
||||
isLoading={isLoading}
|
||||
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -140,10 +140,10 @@ export default function ExamPage({
|
||||
|
||||
useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
/* useEffect(() => {
|
||||
setModuleLock(true);
|
||||
}, [flags.finalizeModule]);
|
||||
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (flags.finalizeModule && !showSolutions) {
|
||||
if (
|
||||
@@ -183,9 +183,9 @@ export default function ExamPage({
|
||||
})
|
||||
);
|
||||
const updatedSolutions = userSolutions.map((solution) => {
|
||||
const completed = results
|
||||
.filter((r) => r !== null)
|
||||
.find((c: any) => c.exercise === solution.exercise);
|
||||
const completed = results.find(
|
||||
(c: any) => c && c.exercise === solution.exercise
|
||||
);
|
||||
return completed || solution;
|
||||
});
|
||||
setUserSolutions(updatedSolutions);
|
||||
|
||||
@@ -43,11 +43,11 @@ export default function RegisterCorporate({
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
||||
|
||||
const { users } = useUsers();
|
||||
const { users } = useUsers({ type: "agent" });
|
||||
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
"An e-mail has been sent, please make sure to check your spam folder!",
|
||||
"An e-mail has been sent, please make sure to check your spam folder!"
|
||||
);
|
||||
|
||||
const onError = (e: Error) => {
|
||||
@@ -83,7 +83,7 @@ export default function RegisterCorporate({
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() =>
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -178,9 +178,10 @@ export default function RegisterCorporate({
|
||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||
options={[
|
||||
{ value: "", label: "No referral" },
|
||||
...users
|
||||
.filter((u) => u.type === "agent")
|
||||
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
|
||||
...users.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
})),
|
||||
]}
|
||||
defaultValue={{ value: "", label: "No referral" }}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
@@ -229,7 +230,7 @@ export default function RegisterCorporate({
|
||||
? availableDurations[
|
||||
value.value as keyof typeof availableDurations
|
||||
].number
|
||||
: 1,
|
||||
: 1
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
|
||||
@@ -23,5 +23,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const entityIdsArray = entityIdsString.split(",");
|
||||
|
||||
if (!["admin", "developer"].includes(user.type)) {
|
||||
// filtering workflows that have user as assignee in at least one of the steps
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray, undefined, user.id));
|
||||
} else {
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { Module } from "@/interfaces";
|
||||
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
|
||||
import { Exam, ExamBase, InstructorGender, LevelExam, ListeningExam, ReadingExam, SpeakingExam, Variant } from "@/interfaces/exam";
|
||||
import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
|
||||
import client from "@/lib/mongodb";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
@@ -10,6 +10,7 @@ import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/a
|
||||
import { generateExamDifferences } from "@/utils/exam.differences";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
import { access } from "fs";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
@@ -18,6 +19,24 @@ const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
// Temporary: Adding UUID here but later move to backend.
|
||||
function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
|
||||
const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
|
||||
|
||||
exam.parts = exam.parts.map((part) => {
|
||||
const updatedExercises = part.exercises.map((exercise: any) => {
|
||||
arraysToUpdate.forEach((arrayName) => {
|
||||
if (exercise[arrayName] && Array.isArray(exercise[arrayName])) {
|
||||
exercise[arrayName] = exercise[arrayName].map((item: any) => (item.uuid ? item : { ...item, uuid: uuidv4() }));
|
||||
}
|
||||
});
|
||||
return exercise;
|
||||
});
|
||||
return { ...part, exercises: updatedExercises };
|
||||
});
|
||||
return exam;
|
||||
}
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await GET(req, res);
|
||||
if (req.method === "POST") return await POST(req, res);
|
||||
@@ -52,7 +71,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
|
||||
|
||||
try {
|
||||
const exam = {
|
||||
let exam = {
|
||||
access: "public", // default access is public
|
||||
...req.body,
|
||||
module: module,
|
||||
@@ -61,6 +80,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Temporary: Adding UUID here but later move to backend.
|
||||
exam = addUUIDs(exam);
|
||||
|
||||
let responseStatus: number;
|
||||
let responseMessage: string;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { flatten, map } from "lodash";
|
||||
import { flatten } from "lodash";
|
||||
import { AccessType, Exam } from "@/interfaces/exam";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { requestUser } from "../../../utils/api";
|
||||
|
||||
@@ -48,4 +48,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
await db.collection("sessions").updateOne({ id: session.id }, { $set: session }, { upsert: true });
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
const sessions = await db.collection("sessions").find<Session>({ user: session.user }, { projection: { id: 1 } }).sort({ date: 1 }).toArray();
|
||||
// Delete old sessions
|
||||
if (sessions.length > 5) {
|
||||
await db.collection("sessions").deleteOne({ id: { $in: sessions.slice(0, sessions.length - 5).map(x => x.id) } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
});
|
||||
console.log('response', response.data);
|
||||
res.status(response.status).json(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,13 +73,9 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
|
||||
}));
|
||||
|
||||
const editableWorkflow: EditableApprovalWorkflow = {
|
||||
...workflow,
|
||||
id: workflow._id?.toString() ?? "",
|
||||
name: workflow.name,
|
||||
entityId: workflow.entityId,
|
||||
requester: user.id, // should it change to the editor?
|
||||
startDate: workflow.startDate,
|
||||
modules: workflow.modules,
|
||||
status: workflow.status,
|
||||
steps: editableSteps,
|
||||
};
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
const handleApproveStep = () => {
|
||||
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
|
||||
if (isLastStep) {
|
||||
if (!confirm(`Are you sure you want to approve the last step? Doing so will change the access type of the exam from confidential to private.`)) return;
|
||||
if (!confirm(`Are you sure you want to approve the last step and complete the approval process?`)) return;
|
||||
}
|
||||
|
||||
const updatedWorkflow: ApprovalWorkflow = {
|
||||
@@ -192,7 +192,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
const examId = currentWorkflow.examId;
|
||||
|
||||
axios
|
||||
.patch(`/api/exam/${examModule}/${examId}`, { access: "private" })
|
||||
.patch(`/api/exam/${examModule}/${examId}`, { approved: true })
|
||||
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
@@ -260,10 +260,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
if (examModule && examId) {
|
||||
const exam = await getExamById(examModule, examId.trim());
|
||||
if (!exam) {
|
||||
toast.error(
|
||||
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
||||
{ toastId: "invalid-exam-id" }
|
||||
);
|
||||
toast.error("Something went wrong while fetching exam!");
|
||||
setViewExamIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -389,7 +386,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
{/* Side panel */}
|
||||
<AnimatePresence mode="wait">
|
||||
<LayoutGroup key="sidePanel">
|
||||
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
|
||||
<section className={`absolute inset-y-0 right-0 h-full overflow-y-auto bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
|
||||
{isPanelOpen && selectedStep && (
|
||||
<motion.div
|
||||
className="p-6"
|
||||
@@ -554,12 +551,16 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden mt-2"
|
||||
>
|
||||
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-40">
|
||||
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-[300px]">
|
||||
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
|
||||
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
|
||||
<>
|
||||
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
||||
{change}
|
||||
<span className="text-mti-purple-light text-lg">{change.charAt(0)}</span>
|
||||
{change.slice(1)}
|
||||
</p>
|
||||
<hr className="my-3 h-[3px] bg-mti-purple-light rounded-full w-full" />
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
|
||||
@@ -576,7 +577,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Input comments here"
|
||||
className="w-full h-40 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
|
||||
className="w-full h-[200px] p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -143,6 +143,9 @@ export default function AssignmentsPage({
|
||||
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
||||
|
||||
const [showApprovedExams, setShowApprovedExams] = useState<boolean>(true);
|
||||
const [showNonApprovedExams, setShowNonApprovedExams] = useState<boolean>(true);
|
||||
|
||||
const { exams } = useExams();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -501,6 +504,23 @@ export default function AssignmentsPage({
|
||||
Random Exams
|
||||
</Checkbox>
|
||||
{!useRandomExams && (
|
||||
<>
|
||||
<Checkbox
|
||||
isChecked={showApprovedExams}
|
||||
onChange={() => {
|
||||
setShowApprovedExams((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
Show approved exams
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={showNonApprovedExams}
|
||||
onChange={() => {
|
||||
setShowNonApprovedExams((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
Show non-approved exams
|
||||
</Checkbox>
|
||||
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||
{selectedModules.map((module) => (
|
||||
<div key={module} className="flex flex-col gap-3 w-full">
|
||||
@@ -508,6 +528,7 @@ export default function AssignmentsPage({
|
||||
{capitalize(module)} Exam
|
||||
</label>
|
||||
<Select
|
||||
isClearable
|
||||
value={{
|
||||
value:
|
||||
examIDs.find((e) => e.module === module)?.id ||
|
||||
@@ -526,12 +547,21 @@ export default function AssignmentsPage({
|
||||
)
|
||||
}
|
||||
options={exams
|
||||
.filter((x) => !x.isDiagnostic && x.module === module)
|
||||
.filter((x) =>
|
||||
!x.isDiagnostic &&
|
||||
x.module === module &&
|
||||
x.access !== "confidential" &&
|
||||
(
|
||||
(x.requiresApproval && showApprovedExams) ||
|
||||
(!x.requiresApproval && showNonApprovedExams)
|
||||
)
|
||||
)
|
||||
.map((x) => ({ value: x.id, label: x.id }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function Home({ user, users }: Props) {
|
||||
const [licenses, setLicenses] = useState(0);
|
||||
|
||||
const { rows, renderSearch } = useListSearch<User>(
|
||||
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||
[["name"], ["email"], ["corporateInformation", "companyInformation", "name"]],
|
||||
users
|
||||
);
|
||||
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||
|
||||
@@ -9,7 +9,11 @@ import clsx from "clsx";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import Input from "@/components/Low/Input";
|
||||
import {findAllowedEntities} from "@/utils/permissions";
|
||||
import {
|
||||
findAllowedEntities,
|
||||
findAllowedEntitiesSomePermissions,
|
||||
groupAllowedEntitiesByPermissions,
|
||||
} from "@/utils/permissions";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import ExamEditorStore from "@/stores/examEditor/types";
|
||||
@@ -18,7 +22,13 @@ import {mapBy, redirect, serialize} from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { Module } from "@/interfaces";
|
||||
import { getExam } from "@/utils/exams.be";
|
||||
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam";
|
||||
import {
|
||||
Exam,
|
||||
Exercise,
|
||||
InteractiveSpeakingExercise,
|
||||
ListeningPart,
|
||||
SpeakingExercise,
|
||||
} from "@/interfaces/exam";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
@@ -27,29 +37,56 @@ import {EntityWithRoles} from "@/interfaces/entity";
|
||||
|
||||
type Permission = { [key in Module]: boolean };
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
||||
export const getServerSideProps = withIronSessionSsr(
|
||||
async ({ req, res, query }) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
const entityIDs = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
|
||||
|
||||
const entities = await getEntitiesWithRoles(
|
||||
isAdmin(user) ? undefined : entityIDs
|
||||
);
|
||||
|
||||
const generatePermissions = groupAllowedEntitiesByPermissions(
|
||||
user,
|
||||
entities,
|
||||
[
|
||||
"generate_reading",
|
||||
"generate_listening",
|
||||
"generate_writing",
|
||||
"generate_speaking",
|
||||
"generate_level",
|
||||
]
|
||||
);
|
||||
|
||||
const permissions: Permission = {
|
||||
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0,
|
||||
listening: findAllowedEntities(user, entities, `generate_listening`).length > 0,
|
||||
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0,
|
||||
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
|
||||
level: findAllowedEntities(user, entities, `generate_level`).length > 0,
|
||||
reading: generatePermissions["generate_reading"].length > 0,
|
||||
listening: generatePermissions["generate_listening"].length > 0,
|
||||
writing: generatePermissions["generate_writing"].length > 0,
|
||||
speaking: generatePermissions["generate_speaking"].length > 0,
|
||||
level: generatePermissions["generate_level"].length > 0,
|
||||
};
|
||||
|
||||
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy");
|
||||
console.log(entitiesAllowEditPrivacy);
|
||||
const {
|
||||
["update_exam_privacy"]: entitiesAllowEditPrivacy,
|
||||
["create_confidential_exams"]: entitiesAllowConfExams,
|
||||
["create_public_exams"]: entitiesAllowPublicExams,
|
||||
} = groupAllowedEntitiesByPermissions(user, entities, [
|
||||
"update_exam_privacy",
|
||||
"create_confidential_exams",
|
||||
"create_public_exams",
|
||||
]);
|
||||
|
||||
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/");
|
||||
if (Object.keys(permissions).every((p) => !permissions[p as Module]))
|
||||
return redirect("/");
|
||||
|
||||
const {id, module: examModule} = query as {id?: string; module?: Module};
|
||||
const { id, module: examModule } = query as {
|
||||
id?: string;
|
||||
module?: Module;
|
||||
};
|
||||
if (!id || !examModule) return { props: serialize({ user, permissions }) };
|
||||
|
||||
//if (!permissions[module]) return redirect("/generation")
|
||||
@@ -58,9 +95,20 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) =
|
||||
if (!exam) return redirect("/generation");
|
||||
|
||||
return {
|
||||
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
|
||||
props: serialize({
|
||||
id,
|
||||
user,
|
||||
exam,
|
||||
examModule,
|
||||
permissions,
|
||||
entitiesAllowEditPrivacy,
|
||||
entitiesAllowConfExams,
|
||||
entitiesAllowPublicExams,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
},
|
||||
sessionOptions
|
||||
);
|
||||
|
||||
export default function Generation({
|
||||
id,
|
||||
@@ -69,6 +117,8 @@ export default function Generation({
|
||||
examModule,
|
||||
permissions,
|
||||
entitiesAllowEditPrivacy,
|
||||
entitiesAllowConfExams,
|
||||
entitiesAllowPublicExams,
|
||||
}: {
|
||||
id: string;
|
||||
user: User;
|
||||
@@ -76,9 +126,13 @@ export default function Generation({
|
||||
examModule?: Module;
|
||||
permissions: Permission;
|
||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||
entitiesAllowPublicExams: EntityWithRoles[];
|
||||
entitiesAllowConfExams: EntityWithRoles[];
|
||||
}) {
|
||||
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
||||
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined);
|
||||
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
||||
dispatch({ type: "UPDATE_ROOT", payload: { updates } });
|
||||
@@ -130,8 +184,14 @@ export default function Generation({
|
||||
}
|
||||
});
|
||||
|
||||
if (state.listening.instructionsState.customInstructionsURL.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL);
|
||||
if (
|
||||
state.listening.instructionsState.customInstructionsURL.startsWith(
|
||||
"blob:"
|
||||
)
|
||||
) {
|
||||
URL.revokeObjectURL(
|
||||
state.listening.instructionsState.customInstructionsURL
|
||||
);
|
||||
}
|
||||
|
||||
state.speaking.sections.forEach((section) => {
|
||||
@@ -150,7 +210,8 @@ export default function Generation({
|
||||
});
|
||||
}
|
||||
if (sectionState.type === "interactiveSpeaking") {
|
||||
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
||||
const interactiveSpeaking =
|
||||
sectionState as InteractiveSpeakingExercise;
|
||||
interactiveSpeaking.prompts.forEach((prompt) => {
|
||||
URL.revokeObjectURL(prompt.video_url);
|
||||
});
|
||||
@@ -162,7 +223,10 @@ export default function Generation({
|
||||
field: "state",
|
||||
value: {
|
||||
...interactiveSpeaking,
|
||||
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})),
|
||||
prompts: interactiveSpeaking.prompts.map((p) => ({
|
||||
...p,
|
||||
video_url: undefined,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -200,14 +264,17 @@ export default function Generation({
|
||||
defaultValue={title}
|
||||
required
|
||||
/>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Module
|
||||
</label>
|
||||
<RadioGroup
|
||||
value={currentModule}
|
||||
onChange={(currentModule) => updateRoot({ currentModule })}
|
||||
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||
{[...MODULE_ARRAY]
|
||||
.filter((m) => permissions[m])
|
||||
.map((x) => (
|
||||
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
|
||||
>
|
||||
{[...MODULE_ARRAY].reduce((acc, x) => {
|
||||
if (permissions[x])
|
||||
acc.push(
|
||||
<Radio value={x} key={x}>
|
||||
{({ checked }) => (
|
||||
<span
|
||||
@@ -233,16 +300,24 @@ export default function Generation({
|
||||
x === "level" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-level/70 border-ielts-level text-white"),
|
||||
)}>
|
||||
: "bg-ielts-level/70 border-ielts-level text-white")
|
||||
)}
|
||||
>
|
||||
{capitalize(x)}
|
||||
</span>
|
||||
)}
|
||||
</Radio>
|
||||
))}
|
||||
);
|
||||
return acc;
|
||||
}, [] as JSX.Element[])}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
|
||||
<ExamEditor
|
||||
levelParts={examLevelParts}
|
||||
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
|
||||
entitiesAllowConfExams={entitiesAllowConfExams}
|
||||
entitiesAllowPublicExams={entitiesAllowPublicExams}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -287,7 +287,7 @@ export default function History({
|
||||
list={filteredStats}
|
||||
renderCard={customContent}
|
||||
searchFields={[]}
|
||||
pageSize={30}
|
||||
pageSize={25}
|
||||
className="lg:!grid-cols-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
41
src/pages/training/grammar.tsx
Normal file
41
src/pages/training/grammar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
return {
|
||||
props: serialize({ user }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
const Grammar: React.FC<{
|
||||
user: User;
|
||||
}> = ({ user }) => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Training | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Grammar;
|
||||
@@ -203,18 +203,6 @@ const Training: React.FC<{
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
|
||||
<>
|
||||
{isNewContentLoading || areRecordsLoading ? (
|
||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||
{isNewContentLoading && (
|
||||
<span className="text-center text-2xl font-bold text-mti-green-light">
|
||||
Assessing your exams, please be patient...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RecordFilter
|
||||
entities={entities}
|
||||
user={user}
|
||||
@@ -241,12 +229,22 @@ const Training: React.FC<{
|
||||
</>
|
||||
)}
|
||||
</RecordFilter>
|
||||
<>
|
||||
{isNewContentLoading || areRecordsLoading ? (
|
||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||
{isNewContentLoading && (
|
||||
<span className="text-center text-2xl font-bold text-mti-green-light">
|
||||
Assessing your exams, please be patient...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{trainingContent.length == 0 && (
|
||||
<div className="flex flex-grow justify-center items-center">
|
||||
<span className="font-semibold ml-1">
|
||||
No training content to display...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!areRecordsLoading &&
|
||||
groupedByTrainingContent &&
|
||||
|
||||
41
src/pages/training/vocabulary.tsx
Normal file
41
src/pages/training/vocabulary.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
return {
|
||||
props: serialize({ user }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
const Vocabulary: React.FC<{
|
||||
user: User;
|
||||
}> = ({ user }) => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Training | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Vocabulary;
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { BsChevronLeft } from "react-icons/bs";
|
||||
import { mapBy, serialize } from "@/utils";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||
import { getUsersWithStats } from "@/utils/users.be";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
@@ -30,12 +30,35 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
entities,
|
||||
"view_student_performance"
|
||||
);
|
||||
|
||||
if (allowedEntities.length === 0) return redirect("/");
|
||||
|
||||
const students = await (checkAccess(user, ["admin", "developer"])
|
||||
? getUsers({ type: "student" })
|
||||
: getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" }));
|
||||
? getUsersWithStats(
|
||||
{ type: "student" },
|
||||
{
|
||||
id: 1,
|
||||
entities: 1,
|
||||
focus: 1,
|
||||
email: 1,
|
||||
name: 1,
|
||||
levels: 1,
|
||||
userStats: 1,
|
||||
studentID: 1,
|
||||
}
|
||||
)
|
||||
: getUsersWithStats(
|
||||
{ type: "student", "entities.id": { in: mapBy(entities, "id") } },
|
||||
{
|
||||
id: 1,
|
||||
entities: 1,
|
||||
focus: 1,
|
||||
email: 1,
|
||||
name: 1,
|
||||
levels: 1,
|
||||
userStats: 1,
|
||||
studentID: 1,
|
||||
}
|
||||
));
|
||||
const groups = await getParticipantsGroups(mapBy(students, "id"));
|
||||
|
||||
return {
|
||||
@@ -45,23 +68,22 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
students: StudentUser[];
|
||||
students: (StudentUser & { userStats: Stat[] })[];
|
||||
entities: Entity[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
const StudentPerformance = ({ user, students, entities, groups }: Props) => {
|
||||
const { data: stats } = useFilterRecordsByUser<Stat[]>();
|
||||
|
||||
const StudentPerformance = ({ students, entities, groups }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const performanceStudents = students.map((u) => ({
|
||||
...u,
|
||||
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||
entitiesLabel: mapBy(u.entities, "id")
|
||||
.map((id) => entities.find((e) => e.id === id)?.label)
|
||||
.filter((e) => !!e)
|
||||
.join(", "),
|
||||
entitiesLabel: (u.entities || []).reduce((acc, curr, idx) => {
|
||||
const entity = entities.find((e) => e.id === curr.id);
|
||||
if (idx === 0) return entity ? entity.label : "";
|
||||
return acc + (entity ? `${entity.label}` : "");
|
||||
}, ""),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -76,7 +98,6 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -91,7 +112,7 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
|
||||
Student Performance ({students.length})
|
||||
</h2>
|
||||
</div>
|
||||
<StudentPerformanceList items={performanceStudents} stats={stats} />
|
||||
<StudentPerformanceList items={performanceStudents} />
|
||||
</>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -59,8 +59,8 @@ const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): Reo
|
||||
let newIds = oldIds.map((_, index) => (startId + index).toString());
|
||||
|
||||
let newSolutions = exercise.solutions.map((solution, index) => ({
|
||||
id: newIds[index],
|
||||
solution: [...solution.solution]
|
||||
...solution,
|
||||
id: newIds[index]
|
||||
}));
|
||||
|
||||
let newText = exercise.text;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ObjectId } from "mongodb";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[]) => {
|
||||
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[], assignee?: string) => {
|
||||
const filters: any = {};
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
@@ -15,7 +15,15 @@ export const getApprovalWorkflows = async (collection: string, entityIds?: strin
|
||||
filters.entityId = { $in: entityIds };
|
||||
}
|
||||
|
||||
return await db.collection<ApprovalWorkflow>(collection).find(filters).toArray();
|
||||
if (assignee) {
|
||||
filters["steps.assignees"] = assignee;
|
||||
}
|
||||
|
||||
return await db
|
||||
.collection<ApprovalWorkflow>(collection)
|
||||
.find(filters)
|
||||
.sort({ startDate: -1 })
|
||||
.toArray();
|
||||
};
|
||||
|
||||
export const getApprovalWorkflow = async (collection: string, id: string) => {
|
||||
@@ -26,6 +34,7 @@ export const getApprovalWorkflowsByEntities = async (collection: string, ids: st
|
||||
return await db
|
||||
.collection<ApprovalWorkflow>(collection)
|
||||
.find({ entityId: { $in: ids } })
|
||||
.sort({ startDate: -1 })
|
||||
.toArray();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { diff, Diff } from "deep-diff";
|
||||
|
||||
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]);
|
||||
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]);
|
||||
|
||||
const PATH_LABELS: Record<string, string> = {
|
||||
access: "Access Type",
|
||||
@@ -24,124 +23,146 @@ const PATH_LABELS: Record<string, string> = {
|
||||
allowRepetition: "Allow Repetition",
|
||||
maxWords: "Max Words",
|
||||
minTimer: "Timer",
|
||||
section: "Section",
|
||||
module: "Module",
|
||||
type: "Type",
|
||||
intro: "Intro",
|
||||
category: "Category",
|
||||
context: "Context",
|
||||
instructions: "Instructions",
|
||||
name: "Name",
|
||||
gender: "Gender",
|
||||
voice: "Voice",
|
||||
enableNavigation: "Enable Navigation",
|
||||
limit: "Limit",
|
||||
instructorGender: "Instructor Gender",
|
||||
wordCounter: "Word Counter",
|
||||
attachment: "Attachment",
|
||||
first_title: "First Title",
|
||||
second_title: "Second Title",
|
||||
first_topic: "First Topic",
|
||||
second_topic: "Second Topic",
|
||||
questions: "Questions",
|
||||
sentences: "Sentences",
|
||||
sentence: "Sentence",
|
||||
solution: "Solution",
|
||||
passage: "Passage",
|
||||
};
|
||||
|
||||
const ARRAY_ITEM_LABELS: Record<string, string> = {
|
||||
exercises: "Exercise",
|
||||
paths: "Path",
|
||||
difficulties: "Difficulty",
|
||||
solutions: "Solution",
|
||||
options: "Option",
|
||||
words: "Word",
|
||||
questions: "Question",
|
||||
userSolutions: "User Solution",
|
||||
sentences: "Sentence",
|
||||
parts: "Part",
|
||||
};
|
||||
|
||||
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
|
||||
const differences = diff(oldExam, newExam) || [];
|
||||
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[];
|
||||
const differences: string[] = [];
|
||||
compareObjects(oldExam, newExam, [], differences);
|
||||
return differences;
|
||||
}
|
||||
|
||||
function formatDifference(change: Diff<any, any>): string | undefined {
|
||||
if (!change.path) return;
|
||||
|
||||
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
|
||||
return;
|
||||
function isObject(val: any): val is Record<string, any> {
|
||||
return val !== null && typeof val === "object" && !Array.isArray(val);
|
||||
}
|
||||
|
||||
const pathString = pathToHumanReadable(change.path);
|
||||
|
||||
switch (change.kind) {
|
||||
case "N": // New property/element
|
||||
return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`;
|
||||
case "D": // Deleted property/element
|
||||
return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`;
|
||||
case "E": // Edited property/element
|
||||
return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`;
|
||||
case "A": // Array change
|
||||
return formatArrayChange(change);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function formatArrayChange(change: Diff<any, any>): string | undefined {
|
||||
if (!change.path) return;
|
||||
|
||||
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathString = pathToHumanReadable(change.path);
|
||||
const arrayChange = (change as any).item;
|
||||
const idx = (change as any).index;
|
||||
|
||||
if (!arrayChange) return;
|
||||
|
||||
switch (arrayChange.kind) {
|
||||
case "N":
|
||||
return `• Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`;
|
||||
case "D":
|
||||
return `• Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`;
|
||||
case "E":
|
||||
return `• Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`;
|
||||
case "A":
|
||||
return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: any): string {
|
||||
if (value === null) return "null";
|
||||
function formatPrimitive(value: any): string {
|
||||
if (value === undefined) return "undefined";
|
||||
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS);
|
||||
|
||||
const renamed = renameKeysDeep(sanitized, PATH_LABELS);
|
||||
|
||||
return JSON.stringify(renamed, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
if (value === null) return "null";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function removeExcludedKeysDeep(obj: any, excludedKeys: Set<string>): any {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => removeExcludedKeysDeep(item, excludedKeys));
|
||||
} else if (obj && typeof obj === "object") {
|
||||
const newObj: any = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (excludedKeys.has(key)) {
|
||||
// Skip this key entirely
|
||||
continue;
|
||||
}
|
||||
newObj[key] = removeExcludedKeysDeep(obj[key], excludedKeys);
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function renameKeysDeep(obj: any, renameMap: Record<string, string>): any {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => renameKeysDeep(item, renameMap));
|
||||
} else if (obj && typeof obj === "object") {
|
||||
const newObj: any = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const newKey = renameMap[key] ?? key; // Use friendly label if available
|
||||
newObj[newKey] = renameKeysDeep(obj[key], renameMap);
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of path segments into a user-friendly string.
|
||||
* e.g. ["parts", 0, "exercises", 1, "prompt"]
|
||||
* → "Parts → [#1] → Exercises → [#2] → Prompt"
|
||||
*/
|
||||
function pathToHumanReadable(pathSegments: Array<string | number>): string {
|
||||
return pathSegments
|
||||
.map((seg) => {
|
||||
const mapped = pathSegments.map((seg) => {
|
||||
if (typeof seg === "number") {
|
||||
return `[#${seg + 1}]`;
|
||||
return `#${seg + 1}`;
|
||||
}
|
||||
return PATH_LABELS[seg] ?? seg;
|
||||
})
|
||||
.join(" → ");
|
||||
});
|
||||
|
||||
let result = "";
|
||||
for (let i = 0; i < mapped.length; i++) {
|
||||
result += mapped[i];
|
||||
if (mapped[i].startsWith("#") && i < mapped.length - 1) {
|
||||
result += " - ";
|
||||
} else if (i < mapped.length - 1) {
|
||||
result += " ";
|
||||
}
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function getArrayItemLabel(path: (string | number)[]): string {
|
||||
if (path.length === 0) return "item";
|
||||
const lastSegment = path[path.length - 1];
|
||||
if (typeof lastSegment === "string" && ARRAY_ITEM_LABELS[lastSegment]) {
|
||||
return ARRAY_ITEM_LABELS[lastSegment];
|
||||
}
|
||||
return "item";
|
||||
}
|
||||
|
||||
function getIdentifier(item: any): string | number | undefined {
|
||||
if (item?.uuid !== undefined) return item.uuid;
|
||||
if (item?.id !== undefined) return item.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compareObjects(oldObj: any, newObj: any, path: (string | number)[], differences: string[]) {
|
||||
if (Array.isArray(oldObj) && Array.isArray(newObj)) {
|
||||
// Check if array elements are objects with an identifier (uuid or id).
|
||||
if (oldObj.length > 0 && typeof oldObj[0] === "object" && getIdentifier(oldObj[0]) !== undefined) {
|
||||
// Process removed items
|
||||
const newIds = new Set(newObj.map((item: any) => getIdentifier(item)));
|
||||
for (let i = 0; i < oldObj.length; i++) {
|
||||
const oldItem = oldObj[i];
|
||||
const identifier = getIdentifier(oldItem);
|
||||
if (identifier !== undefined && !newIds.has(identifier)) {
|
||||
differences.push(`• Removed ${getArrayItemLabel(path)} #${i + 1} from ${pathToHumanReadable(path)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const oldIndexMap = new Map(oldObj.map((item: any, index: number) => [getIdentifier(item), index]));
|
||||
// Process items in the new array using their order.
|
||||
for (let i = 0; i < newObj.length; i++) {
|
||||
const newItem = newObj[i];
|
||||
const identifier = getIdentifier(newItem);
|
||||
if (identifier !== undefined) {
|
||||
if (oldIndexMap.has(identifier)) {
|
||||
const oldIndex = oldIndexMap.get(identifier)!;
|
||||
const oldItem = oldObj[oldIndex];
|
||||
compareObjects(oldItem, newItem, path.concat(`#${i + 1}`), differences);
|
||||
} else {
|
||||
differences.push(`• Added new ${getArrayItemLabel(path)} #${i + 1} at ${pathToHumanReadable(path)}\n`);
|
||||
}
|
||||
} else {
|
||||
// Fallback: if item does not have an identifier, compare by index.
|
||||
compareObjects(oldObj[i], newItem, path.concat(`#${i + 1}`), differences);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For arrays that are not identifier-based, compare element by element.
|
||||
const maxLength = Math.max(oldObj.length, newObj.length);
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
compareObjects(oldObj[i], newObj[i], path.concat(`#${i + 1}`), differences);
|
||||
}
|
||||
}
|
||||
} else if (isObject(oldObj) && isObject(newObj)) {
|
||||
// Compare objects by keys (ignoring excluded keys).
|
||||
const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
||||
for (const key of keys) {
|
||||
if (EXCLUDED_KEYS.has(key)) continue;
|
||||
compareObjects(oldObj[key], newObj[key], path.concat(key), differences);
|
||||
}
|
||||
} else {
|
||||
if (oldObj !== newObj) {
|
||||
differences.push(`• Changed ${pathToHumanReadable(path)} from:\n ${formatPrimitive(oldObj)}\n To:\n ${formatPrimitive(newObj)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +70,9 @@ export const getExamById = async (module: Module, id: string): Promise<Exam | un
|
||||
|
||||
export const defaultExamUserSolutions = (exam: Exam) => {
|
||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level")
|
||||
return exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam));
|
||||
return (exam.parts.flatMap((x) => x.exercises) ?? []).map((x) => defaultUserSolutions(x, exam));
|
||||
|
||||
return exam.exercises.map((x) => defaultUserSolutions(x, exam));
|
||||
return (exam.exercises ?? []).map((x) => defaultUserSolutions(x, exam));
|
||||
};
|
||||
|
||||
export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => {
|
||||
|
||||
@@ -193,17 +193,18 @@ export const getGradingLabel = (score: number, grading: Step[]) => {
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
export const averageLevelCalculator = (users: User[], studentStats: Stat[]) => {
|
||||
const formattedStats = studentStats
|
||||
export const averageLevelCalculator = (focus: Type, studentStats: Stat[]) => {
|
||||
/* const formattedStats = studentStats
|
||||
.map((s) => ({
|
||||
focus: users.find((u) => u.id === s.user)?.focus,
|
||||
focus: focus,
|
||||
score: s.score,
|
||||
module: s.module,
|
||||
}))
|
||||
.filter((f) => !!f.focus);
|
||||
const bandScores = formattedStats.map((s) => ({
|
||||
.filter((f) => !!f.focus); */
|
||||
|
||||
const bandScores = studentStats.map((s) => ({
|
||||
module: s.module,
|
||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, focus),
|
||||
}));
|
||||
|
||||
const levels: { [key in Module]: number } = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import client from "@/lib/mongodb";
|
||||
import { EntityWithRoles, WithEntities } from "@/interfaces/entity";
|
||||
import { getEntity } from "./entities.be";
|
||||
import { getRole } from "./roles.be";
|
||||
import { findAllowedEntities, groupAllowedEntitiesByPermissions } from "./permissions";
|
||||
import { groupAllowedEntitiesByPermissions } from "./permissions";
|
||||
import { mapBy } from ".";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
@@ -20,6 +20,15 @@ export async function getUsers(filter?: object, limit = 0, sort = {}, projection
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function getUsersWithStats(filter?: object, projection = {}, limit = 0, sort = {}) {
|
||||
return await db
|
||||
.collection("usersWithStats")
|
||||
.find<User>(filter || {}, { projection: { _id: 0, ...projection } })
|
||||
.limit(limit)
|
||||
.sort(sort)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function searchUsers(searchInput?: string, limit = 50, page = 0, sort: object = { "name": 1 }, projection = {}, filter?: object) {
|
||||
const compoundFilter = {
|
||||
"compound": {
|
||||
@@ -266,12 +275,13 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
|
||||
'view_corporates',
|
||||
'view_mastercorporates',
|
||||
]);
|
||||
console.log(mapBy(allowedStudentEntities, 'id'))
|
||||
const [student, teacher, corporate, mastercorporate] = await Promise.all([
|
||||
countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
|
||||
countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
|
||||
countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
|
||||
countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
|
||||
])
|
||||
|
||||
console.log(student)
|
||||
return { student, teacher, corporate, mastercorporate }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user