Compare commits
46 Commits
workflow-p
...
fixStudent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f2a21a76 | ||
|
|
c49b1c8070 | ||
|
|
655e019bf6 | ||
|
|
d7a8f496c0 | ||
|
|
5e363e9951 | ||
|
|
3370f3c648 | ||
|
|
d77336374d | ||
|
|
e765dea106 | ||
|
|
75fb9490e0 | ||
|
|
3ef7998193 | ||
|
|
32cd8495d6 | ||
|
|
4e3cfec9e8 | ||
|
|
ba8cc342b1 | ||
|
|
dd8f821e35 | ||
|
|
a4ef2222e2 | ||
|
|
93d9e49358 | ||
|
|
5d0a3acbee | ||
|
|
340ff5a30a | ||
|
|
37908423eb | ||
|
|
b388ee399f | ||
|
|
4ac11df6ae | ||
|
|
14e2702aca | ||
|
|
fec3b51553 | ||
|
|
d8386bdd8e | ||
|
|
df2f83e496 | ||
|
|
e214d8b598 | ||
|
|
c14f16c97a | ||
|
|
ca2cf739ee | ||
|
|
d432fb4bc4 | ||
|
|
d5bffc9bad | ||
|
|
75b4643918 | ||
|
|
9ae6b8e894 | ||
|
|
6f6c5a4209 | ||
|
|
769b1b39d3 | ||
|
|
4bb12c7f01 | ||
|
|
a80a342ae2 | ||
|
|
e5e60fcce9 | ||
|
|
b175d8797e | ||
|
|
f06349e350 | ||
|
|
34caf9986c | ||
|
|
3a3d3d014d | ||
|
|
c49c303f20 | ||
|
|
cbe353c2c5 | ||
|
|
991adede96 | ||
|
|
f95bce6fa2 | ||
|
|
1dd6cead9e |
@@ -114,5 +114,6 @@
|
||||
"husky": "^8.0.3",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
51
scripts/updatePrivateFieldExams.js
Normal file
51
scripts/updatePrivateFieldExams.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { MongoClient } from "mongodb";
|
||||
const uri = process.env.MONGODB_URI || "";
|
||||
const options = {
|
||||
maxPoolSize: 10,
|
||||
};
|
||||
const dbName = process.env.MONGODB_DB; // change this to prod db when needed
|
||||
async function migrateData() {
|
||||
const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"];
|
||||
const client = new MongoClient(uri, options);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("Connected to MongoDB");
|
||||
if (!process.env.MONGODB_DB) {
|
||||
throw new Error("Missing env var: MONGODB_DB");
|
||||
}
|
||||
const db = client.db(dbName);
|
||||
for (const string of MODULE_ARRAY) {
|
||||
const collection = db.collection(string);
|
||||
const result = await collection.updateMany(
|
||||
{ private: { $exists: false } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
const result2 = await collection.updateMany(
|
||||
{ private: true },
|
||||
{ $set: { access: "private" }, $unset: { private: "" } }
|
||||
);
|
||||
const result1 = await collection.updateMany(
|
||||
{ private: { $exists: true } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
console.log(
|
||||
`Updated ${
|
||||
result.modifiedCount + result1.modifiedCount
|
||||
} documents to "access: public" in ${string}`
|
||||
);
|
||||
console.log(
|
||||
`Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}`
|
||||
);
|
||||
}
|
||||
console.log("Migration completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log("MongoDB connection closed.");
|
||||
}
|
||||
}
|
||||
//migrateData(); // uncomment to run the migration
|
||||
@@ -1,17 +1,12 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExam, getExamById} from "@/utils/exams";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
import {Menu} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||
import { useState} from "react";
|
||||
import { BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||
|
||||
@@ -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: [""]
|
||||
}];
|
||||
|
||||
@@ -19,7 +19,7 @@ interface SettingsEditorProps {
|
||||
children?: ReactNode;
|
||||
canPreview: boolean;
|
||||
canSubmit: boolean;
|
||||
submitModule: () => void;
|
||||
submitModule: (requiresApproval: boolean) => void;
|
||||
preview: () => void;
|
||||
}
|
||||
|
||||
@@ -148,18 +148,33 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
</div>
|
||||
</Dropdown>
|
||||
{children}
|
||||
<div className="flex flex-row justify-between mt-4">
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||
"disabled:cursor-not-allowed disabled:text-gray-200"
|
||||
)}
|
||||
onClick={submitModule}
|
||||
onClick={() => submitModule(true)}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<FaFileUpload className="mr-2" size={18} />
|
||||
Submit Module as Exam
|
||||
Submit module as exam for approval
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||
"disabled:cursor-not-allowed disabled:text-gray-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!confirm(`Are you sure you want to skip the approval process for this exam?`)) return;
|
||||
submitModule(false);
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<FaFileUpload className="mr-2" size={18} />
|
||||
Submit module as exam and skip approval process
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -171,7 +186,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
disabled={!canPreview}
|
||||
>
|
||||
<FaEye className="mr-2" size={18} />
|
||||
Preview Module
|
||||
Preview module
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ const LevelSettings: React.FC = () => {
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
access,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
|
||||
@@ -76,7 +76,7 @@ const LevelSettings: React.FC = () => {
|
||||
});
|
||||
});
|
||||
|
||||
const submitLevel = async () => {
|
||||
const submitLevel = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -195,12 +195,13 @@ const LevelSettings: React.FC = () => {
|
||||
category: s.settings.category
|
||||
};
|
||||
}).filter(part => part.exercises.length > 0),
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/level', exam);
|
||||
@@ -243,7 +244,7 @@ const LevelSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
} as LevelExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
|
||||
@@ -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();
|
||||
@@ -27,7 +20,7 @@ const ListeningSettings: React.FC = () => {
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
access,
|
||||
instructionsState
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
@@ -65,7 +58,7 @@ const ListeningSettings: React.FC = () => {
|
||||
}
|
||||
];
|
||||
|
||||
const submitListening = async () => {
|
||||
const submitListening = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -138,13 +131,14 @@ const ListeningSettings: React.FC = () => {
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
instructions: instructionsURL
|
||||
};
|
||||
|
||||
@@ -191,7 +185,7 @@ const ListeningSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
instructions: instructionsState.currentInstructionsURL
|
||||
} as ListeningExam);
|
||||
setExerciseIndex(0);
|
||||
|
||||
@@ -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,138 +12,140 @@ 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();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
type,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, access, type } =
|
||||
useExamEditorStore((state) => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection
|
||||
);
|
||||
const { localSettings, updateLocalAndScheduleGlobal } =
|
||||
useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
|
||||
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
|
||||
const currentSection = sections.find(
|
||||
(section) => section.sectionId == focusedSection
|
||||
)?.state as ReadingPart;
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Reading Passage 1",
|
||||
value:
|
||||
"Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas.",
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 2",
|
||||
value:
|
||||
"Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views.",
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 3",
|
||||
value:
|
||||
"Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas.",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Reading Passage 1",
|
||||
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 2",
|
||||
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 3",
|
||||
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
|
||||
}
|
||||
];
|
||||
const canPreviewOrSubmit = sections.some(
|
||||
(s) =>
|
||||
(s.state as ReadingPart).exercises &&
|
||||
(s.state as ReadingPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const canPreviewOrSubmit = sections.some(
|
||||
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const submitReading = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ReadingExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
variant: sections.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
const submitReading = (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ReadingExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category,
|
||||
};
|
||||
}),
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
variant: sections.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
access,
|
||||
type: type!,
|
||||
};
|
||||
|
||||
axios.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
||||
})
|
||||
}
|
||||
axios
|
||||
.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(
|
||||
error.response.data.error ||
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercises = s.state as ReadingPart;
|
||||
return {
|
||||
...exercises,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
} as ReadingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router)
|
||||
}
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercises = s.state as ReadingPart;
|
||||
return {
|
||||
...exercises,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category,
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
access: access,
|
||||
type: type!,
|
||||
} as ReadingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Passage ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="reading"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitReading}
|
||||
>
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Passage ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="reading"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitReading}
|
||||
>
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingSettings;
|
||||
|
||||
@@ -30,7 +30,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { title, currentModule } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
const { focusedSection, difficulty, sections, minTimer, access } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
|
||||
|
||||
@@ -84,7 +84,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
});
|
||||
})();
|
||||
|
||||
const submitSpeaking = async () => {
|
||||
const submitSpeaking = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -181,11 +181,12 @@ const SpeakingSettings: React.FC = () => {
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
private: isPrivate,
|
||||
access,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/speaking', exam);
|
||||
@@ -238,7 +239,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
} as SpeakingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
|
||||
@@ -23,7 +23,7 @@ const WritingSettings: React.FC = () => {
|
||||
const {
|
||||
minTimer,
|
||||
difficulty,
|
||||
isPrivate,
|
||||
access,
|
||||
sections,
|
||||
focusedSection,
|
||||
type,
|
||||
@@ -81,14 +81,14 @@ const WritingSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
type: type!
|
||||
});
|
||||
setExerciseIndex(0);
|
||||
openDetachedTab("popout?type=Exam&module=writing", router)
|
||||
}
|
||||
|
||||
const submitWriting = async () => {
|
||||
const submitWriting = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -131,10 +131,11 @@ const WritingSettings: React.FC = () => {
|
||||
minTimer,
|
||||
module: "writing",
|
||||
id: title,
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
type: type!
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 {Difficulty} from "@/interfaces/exam";
|
||||
import {useCallback, useEffect, useMemo, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {ModuleState, SectionState} from "@/stores/examEditor/types";
|
||||
import {Module} from "@/interfaces";
|
||||
import { capitalize } from "lodash";
|
||||
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
||||
import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import WritingSettings from "./SettingsEditor/writing";
|
||||
import ReadingSettings from "./SettingsEditor/reading";
|
||||
@@ -16,243 +15,329 @@ import LevelSettings from "./SettingsEditor/level";
|
||||
import ListeningSettings from "./SettingsEditor/listening";
|
||||
import SpeakingSettings from "./SettingsEditor/speaking";
|
||||
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
||||
import {defaultSectionSettings} from "@/stores/examEditor/defaults";
|
||||
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
||||
import Button from "../Low/Button";
|
||||
import ResetModule from "./Standalone/ResetModule";
|
||||
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||
import {EntityWithRoles} from "@/interfaces/entity";
|
||||
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 ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: EntityWithRoles[]}> = ({
|
||||
levelParts = 0,
|
||||
entitiesAllowEditPrivacy = [],
|
||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings,
|
||||
};
|
||||
|
||||
const ExamEditor: React.FC<{
|
||||
levelParts?: number;
|
||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||
entitiesAllowConfExams: EntityWithRoles[];
|
||||
entitiesAllowPublicExams: EntityWithRoles[];
|
||||
}> = ({
|
||||
levelParts = 0,
|
||||
entitiesAllowEditPrivacy = [],
|
||||
entitiesAllowConfExams = [],
|
||||
entitiesAllowPublicExams = [],
|
||||
}) => {
|
||||
const {currentModule, dispatch} = useExamEditorStore();
|
||||
const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore(
|
||||
(state) => state.modules[currentModule],
|
||||
);
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
sections,
|
||||
minTimer,
|
||||
expandedSections,
|
||||
examLabel,
|
||||
access,
|
||||
difficulty,
|
||||
sectionLabels,
|
||||
importModule,
|
||||
} = useExamEditorStore((state) => state.modules[currentModule]);
|
||||
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(
|
||||
levelParts !== 0 ? levelParts : 1
|
||||
);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
|
||||
// For exam edits
|
||||
useEffect(() => {
|
||||
if (levelParts !== 0) {
|
||||
setNumberOfLevelParts(levelParts);
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sectionLabels: Array.from({length: levelParts}).map((_, i) => ({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
})),
|
||||
},
|
||||
module: "level",
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelParts]);
|
||||
// For exam edits
|
||||
useEffect(() => {
|
||||
if (levelParts !== 0) {
|
||||
setNumberOfLevelParts(levelParts);
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
})),
|
||||
},
|
||||
module: "level",
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelParts]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSections = sections;
|
||||
const currentLabels = sectionLabels;
|
||||
let updatedSections: SectionState[];
|
||||
let updatedLabels: any;
|
||||
if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) {
|
||||
const newSections = [...currentSections];
|
||||
const newLabels = [...currentLabels];
|
||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||
newLabels.push({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
});
|
||||
}
|
||||
updatedSections = newSections;
|
||||
updatedLabels = newLabels;
|
||||
} else if (numberOfLevelParts < currentSections.length) {
|
||||
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
||||
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const currentSections = sections;
|
||||
const currentLabels = sectionLabels;
|
||||
let updatedSections: SectionState[];
|
||||
let updatedLabels: any;
|
||||
if (
|
||||
(currentModule === "level" &&
|
||||
currentSections.length !== currentLabels.length) ||
|
||||
numberOfLevelParts !== currentSections.length
|
||||
) {
|
||||
const newSections = [...currentSections];
|
||||
const newLabels = [...currentLabels];
|
||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||
if (currentSections.length !== numberOfLevelParts)
|
||||
newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||
newLabels.push({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
});
|
||||
}
|
||||
updatedSections = newSections;
|
||||
updatedLabels = newLabels;
|
||||
} else if (numberOfLevelParts < currentSections.length) {
|
||||
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
||||
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId));
|
||||
const updatedExpandedSections = expandedSections.filter((sectionId) =>
|
||||
updatedSections.some((section) => section.sectionId === sectionId)
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sections: updatedSections,
|
||||
sectionLabels: updatedLabels,
|
||||
expandedSections: updatedExpandedSections,
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numberOfLevelParts]);
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sections: updatedSections,
|
||||
sectionLabels: updatedLabels,
|
||||
expandedSections: updatedExpandedSections,
|
||||
},
|
||||
},
|
||||
});
|
||||
// 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>) => {
|
||||
dispatch({type: "UPDATE_MODULE", payload: {updates}});
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const updateModule = useCallback(
|
||||
(updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const toggleSection = (sectionId: number) => {
|
||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||
toast.error("Include at least one section!");
|
||||
return;
|
||||
}
|
||||
dispatch({type: "TOGGLE_SECTION", payload: {sectionId}});
|
||||
};
|
||||
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) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
};
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showImport ? (
|
||||
<ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} />
|
||||
) : (
|
||||
<>
|
||||
{isResetModuleOpen && (
|
||||
<ResetModule
|
||||
module={currentModule}
|
||||
isOpen={isResetModuleOpen}
|
||||
setIsOpen={setIsResetModuleOpen}
|
||||
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="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) =>
|
||||
updateModule({
|
||||
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||
})
|
||||
}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES.map((x) => ({
|
||||
value: x,
|
||||
label: capitalize(x),
|
||||
}))}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values ? values.map((v) => v.value as Difficulty) : [];
|
||||
updateModule({difficulty: selectedDifficulties});
|
||||
}}
|
||||
value={
|
||||
difficulty
|
||||
? difficulty.map((d) => ({
|
||||
value: d,
|
||||
label: capitalize(d),
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
||||
<div className="flex flex-col gap-3 -xl:w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
|
||||
<div className="flex flex-row gap-8">
|
||||
{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",
|
||||
"transition duration-300 ease-in-out",
|
||||
sectionIds.includes(id)
|
||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||
: "bg-white border-mti-gray-platinum",
|
||||
)}
|
||||
onClick={() => toggleSection(id)}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="Number of Parts"
|
||||
min={1}
|
||||
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
|
||||
value={numberOfLevelParts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||
<div className="h-6" />
|
||||
<Checkbox
|
||||
isChecked={isPrivate}
|
||||
onChange={(checked) => updateModule({isPrivate: checked})}
|
||||
disabled={entitiesAllowEditPrivacy.length === 0}>
|
||||
Privacy (Only available for Assignments)
|
||||
</Checkbox>
|
||||
</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">Exam Label *</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Exam Label"
|
||||
name="label"
|
||||
onChange={(text) => updateModule({examLabel: text})}
|
||||
roundness="xl"
|
||||
value={examLabel}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{currentModule === "listening" && <ListeningInstructions />}
|
||||
<Button
|
||||
onClick={() => setIsResetModuleOpen(true)}
|
||||
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
||||
className={`text-white self-end`}>
|
||||
Reset Module
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-8 -2xl:flex-col">
|
||||
<Settings />
|
||||
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||
<SectionRenderer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const updateLevelParts = useCallback((parts: number) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showImport ? (
|
||||
<ImportOrStartFromScratch
|
||||
module={currentModule}
|
||||
setNumberOfLevelParts={updateLevelParts}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isResetModuleOpen && (
|
||||
<ResetModule
|
||||
module={currentModule}
|
||||
isOpen={isResetModuleOpen}
|
||||
setIsOpen={setIsResetModuleOpen}
|
||||
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) =>
|
||||
updateModule({
|
||||
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||
})
|
||||
}
|
||||
value={minTimer}
|
||||
className="max-w-[125px] min-w-[100px] w-min"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 ">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Difficulty
|
||||
</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values
|
||||
? values.map((v) => v.value as Difficulty)
|
||||
: [];
|
||||
updateModule({ difficulty: selectedDifficulties });
|
||||
}}
|
||||
value={
|
||||
difficulty
|
||||
? (Array.isArray(difficulty)
|
||||
? difficulty
|
||||
: [difficulty]
|
||||
).map((d) => ({
|
||||
value: d,
|
||||
label: capitalize(d),
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
||||
<div className="flex flex-col gap-3 -xl:w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{sectionLabels[0].label.split(" ")[0]}
|
||||
</label>
|
||||
<div className="flex flex-row gap-3">
|
||||
{sectionLabels.map(({ id, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={clsx(
|
||||
"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`
|
||||
: "bg-white border-mti-gray-platinum"
|
||||
)}
|
||||
onClick={() => toggleSection(id)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Number of Parts
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="Number of Parts"
|
||||
min={1}
|
||||
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
|
||||
value={numberOfLevelParts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-[200px] w-full">
|
||||
<Select
|
||||
label="Access Type"
|
||||
disabled={
|
||||
accessTypeOptions.length === 0 ||
|
||||
entitiesAllowEditPrivacy.length === 0
|
||||
}
|
||||
options={accessTypeOptions}
|
||||
onChange={(value) => {
|
||||
if (value?.value) {
|
||||
updateModule({ access: value.value! as AccessType });
|
||||
}
|
||||
}}
|
||||
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">
|
||||
Exam Label *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Exam Label"
|
||||
name="label"
|
||||
onChange={(text) => updateModule({ examLabel: text })}
|
||||
roundness="xl"
|
||||
value={examLabel}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{currentModule === "listening" && <ListeningInstructions />}
|
||||
<Button
|
||||
onClick={() => setIsResetModuleOpen(true)}
|
||||
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
||||
className={`text-white self-end`}
|
||||
>
|
||||
Reset Module
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-8 -xl:flex-col">
|
||||
<Settings />
|
||||
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||
<SectionRenderer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamEditor;
|
||||
|
||||
@@ -149,10 +149,16 @@ export default function Table<T>({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading && (
|
||||
{isLoading ? (
|
||||
<div className="min-h-screen flex justify-center items-start">
|
||||
<span className="loading loading-infinity w-32" />
|
||||
</div>
|
||||
) : (
|
||||
rows.length === 0 && (
|
||||
<div className="w-full flex justify-center items-start">
|
||||
<span className="text-xl text-gray-500">No data found...</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
const entitiesToSearch = useMemo(() => {
|
||||
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";
|
||||
|
||||
@@ -121,12 +121,12 @@ export default function Sidebar({
|
||||
entities,
|
||||
"view_statistics"
|
||||
);
|
||||
|
||||
const entitiesAllowPaymentRecord = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_payment_record"
|
||||
);
|
||||
|
||||
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
|
||||
user,
|
||||
entities,
|
||||
@@ -148,7 +148,7 @@ export default function Sidebar({
|
||||
viewTickets: true,
|
||||
viewClassrooms: true,
|
||||
viewSettings: true,
|
||||
viewPaymentRecord: true,
|
||||
viewPaymentRecords: true,
|
||||
viewGeneration: true,
|
||||
viewApprovalWorkflows: true,
|
||||
};
|
||||
@@ -160,7 +160,7 @@ export default function Sidebar({
|
||||
viewTickets: false,
|
||||
viewClassrooms: false,
|
||||
viewSettings: false,
|
||||
viewPaymentRecord: false,
|
||||
viewPaymentRecords: false,
|
||||
viewGeneration: false,
|
||||
viewApprovalWorkflows: false,
|
||||
};
|
||||
@@ -235,7 +235,7 @@ export default function Sidebar({
|
||||
) &&
|
||||
entitiesAllowPaymentRecord.length > 0
|
||||
) {
|
||||
sidebarPermissions["viewPaymentRecord"] = true;
|
||||
sidebarPermissions["viewPaymentRecords"] = true;
|
||||
}
|
||||
return sidebarPermissions;
|
||||
}, [
|
||||
@@ -378,7 +378,6 @@ export default function Sidebar({
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav
|
||||
@@ -427,6 +426,16 @@ export default function Sidebar({
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewPaymentRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewSettings"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
@@ -459,7 +468,7 @@ export default function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
|
||||
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8 ">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
@@ -483,7 +492,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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function useApprovalWorkflows() {
|
||||
export default function useApprovalWorkflows(entitiesString?: string) {
|
||||
const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
@@ -10,7 +10,7 @@ export default function useApprovalWorkflows() {
|
||||
const getData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<ApprovalWorkflow[]>(`/api/approval-workflows`)
|
||||
.get<ApprovalWorkflow[]>(`/api/approval-workflows`, {params: { entityIds: entitiesString }})
|
||||
.then((response) => setWorkflows(response.data))
|
||||
.catch((error) => {
|
||||
setIsError(true);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useExams() {
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Exam[]>("/api/exam")
|
||||
.then((response) => setExams(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Exam[]>(`/api/exam`)
|
||||
.then((response) => setExams(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
useEffect(getData, []);
|
||||
|
||||
return {exams, isLoading, isError, reload: getData};
|
||||
return { exams, isLoading, isError, reload: getData };
|
||||
}
|
||||
|
||||
@@ -1,60 +1,138 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import {useMemo, useState} from "react";
|
||||
import {BiChevronLeft} from "react-icons/bi";
|
||||
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BsChevronDoubleLeft,
|
||||
BsChevronDoubleRight,
|
||||
BsChevronLeft,
|
||||
BsChevronRight,
|
||||
} from "react-icons/bs";
|
||||
import Select from "../components/Low/Select";
|
||||
|
||||
export default function usePagination<T>(list: T[], size = 25) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(size);
|
||||
|
||||
const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]);
|
||||
const items = useMemo(
|
||||
() => list.slice(page * itemsPerPage, (page + 1) * itemsPerPage),
|
||||
[list, page, itemsPerPage]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (page * itemsPerPage >= list.length) setPage(0);
|
||||
}, [items, itemsPerPage, list.length, page]);
|
||||
|
||||
const render = () => (
|
||||
<div className="w-full flex gap-2 justify-between items-center">
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<span className="opacity-80">
|
||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
||||
</span>
|
||||
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const itemsPerPageOptions = [25, 50, 100, 200];
|
||||
|
||||
const renderMinimal = () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronDoubleLeft />
|
||||
</button>
|
||||
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronLeft />
|
||||
</button>
|
||||
</div>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
||||
</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={(page + 1) * size >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronRight />
|
||||
</button>
|
||||
<button
|
||||
disabled={(page + 1) * size >= list.length}
|
||||
onClick={() => setPage(Math.floor(list.length / size))}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronDoubleRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const render = () => (
|
||||
<div className="w-full flex gap-2 justify-between items-center">
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<Button
|
||||
className="w-[200px] h-fit"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((prev) => prev - 1)}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<div className="flex flex-row items-center gap-1 w-56">
|
||||
<Select
|
||||
value={{
|
||||
value: itemsPerPage.toString(),
|
||||
label: itemsPerPage.toString(),
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setItemsPerPage(parseInt(value!.value ?? "25"))
|
||||
}
|
||||
options={itemsPerPageOptions.map((size) => ({
|
||||
label: size.toString(),
|
||||
value: size.toString(),
|
||||
}))}
|
||||
isClearable={false}
|
||||
styles={{
|
||||
control: (styles) => ({ ...styles, width: "100px" }),
|
||||
container: (styles) => ({ ...styles, width: "100px" }),
|
||||
}}
|
||||
/>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * itemsPerPage + 1} -{" "}
|
||||
{itemsPerPage * (page + 1) > list.length
|
||||
? list.length
|
||||
: itemsPerPage * (page + 1)}
|
||||
{list.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-[200px]"
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return {page, items, setPage, render, renderMinimal};
|
||||
const renderMinimal = () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronDoubleLeft />
|
||||
</button>
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((prev) => prev - 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronLeft />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1 w-56">
|
||||
<Select
|
||||
value={{
|
||||
value: itemsPerPage.toString(),
|
||||
label: itemsPerPage.toString(),
|
||||
}}
|
||||
onChange={(value) => setItemsPerPage(parseInt(value!.value ?? "25"))}
|
||||
options={itemsPerPageOptions.map((size) => ({
|
||||
label: size.toString(),
|
||||
value: size.toString(),
|
||||
}))}
|
||||
isClearable={false}
|
||||
styles={{
|
||||
control: (styles) => ({ ...styles, width: "100px" }),
|
||||
container: (styles) => ({ ...styles, width: "100px" }),
|
||||
}}
|
||||
/>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * itemsPerPage + 1} -{" "}
|
||||
{itemsPerPage * (page + 1) > list.length
|
||||
? list.length
|
||||
: itemsPerPage * (page + 1)}
|
||||
/ {list.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronRight />
|
||||
</button>
|
||||
<button
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage(Math.floor(list.length / itemsPerPage))}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronDoubleRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { page, items, setPage, render, renderMinimal };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import instructions from "@/pages/api/exam/media/instructions";
|
||||
import { Module } from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
@@ -10,6 +9,9 @@ export type Difficulty = BasicDifficulty | CEFRLevels;
|
||||
// Left easy, medium and hard to support older exam versions
|
||||
export type BasicDifficulty = "easy" | "medium" | "hard";
|
||||
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2";
|
||||
export const ACCESSTYPE = ["public", "private", "confidential"] as const;
|
||||
export type AccessType = typeof ACCESSTYPE[number];
|
||||
|
||||
|
||||
|
||||
export interface ExamBase {
|
||||
@@ -24,8 +26,10 @@ export interface ExamBase {
|
||||
shuffle?: boolean;
|
||||
createdBy?: string; // option as it has been added later
|
||||
createdAt?: string; // option as it has been added later
|
||||
private?: boolean;
|
||||
access: AccessType;
|
||||
label?: string;
|
||||
requiresApproval?: boolean;
|
||||
approved?: boolean;
|
||||
}
|
||||
export interface ReadingExam extends ExamBase {
|
||||
module: "reading";
|
||||
@@ -238,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;
|
||||
@@ -255,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"
|
||||
}[];
|
||||
@@ -278,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"
|
||||
@@ -290,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)
|
||||
}[];
|
||||
@@ -316,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;
|
||||
}
|
||||
@@ -343,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"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be";
|
||||
import client from "@/lib/mongodb";
|
||||
|
||||
export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) {
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
/* export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) {
|
||||
const results = await Promise.all(
|
||||
examEntities.map(async (entity) => {
|
||||
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
|
||||
@@ -27,6 +30,53 @@ export async function createApprovalWorkflowsOnExamCreation(examAuthor: string,
|
||||
const successCount = results.filter((r) => r.created).length;
|
||||
const totalCount = examEntities.length;
|
||||
|
||||
return {
|
||||
successCount,
|
||||
totalCount,
|
||||
};
|
||||
} */
|
||||
|
||||
// TEMPORARY BEHAVIOUR! ONLY THE FIRST CONFIGURED WORKFLOW FOUND IS STARTED
|
||||
export async function createApprovalWorkflowOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) {
|
||||
let successCount = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
for (const entity of examEntities) {
|
||||
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
|
||||
|
||||
if (!configuredWorkflow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCount = 1; // a workflow was found
|
||||
|
||||
configuredWorkflow.modules.push(examModule as Module);
|
||||
configuredWorkflow.name = examId;
|
||||
configuredWorkflow.examId = examId;
|
||||
configuredWorkflow.entityId = entity;
|
||||
configuredWorkflow.startDate = Date.now();
|
||||
configuredWorkflow.steps[0].completed = true;
|
||||
configuredWorkflow.steps[0].completedBy = examAuthor;
|
||||
configuredWorkflow.steps[0].completedDate = Date.now();
|
||||
|
||||
try {
|
||||
await createApprovalWorkflow("active-workflows", configuredWorkflow);
|
||||
successCount = 1;
|
||||
break; // Stop after the first success
|
||||
} catch (error: any) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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, access: "private" }},
|
||||
{ upsert: true }
|
||||
);
|
||||
} */
|
||||
|
||||
return {
|
||||
successCount,
|
||||
totalCount,
|
||||
|
||||
@@ -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,444 +13,587 @@ 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[] };
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
} = {
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
},
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[]
|
||||
onFinish: () => void;
|
||||
user: User;
|
||||
users: User[];
|
||||
permissions: PermissionType[];
|
||||
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 }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | 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 }>();
|
||||
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
|
||||
);
|
||||
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 { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const schema = {
|
||||
'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
|
||||
}
|
||||
},
|
||||
'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
|
||||
}
|
||||
},
|
||||
'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
|
||||
}
|
||||
},
|
||||
'E-mail': {
|
||||
prop: 'email',
|
||||
required: true,
|
||||
type: (value: any) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('Email cannot be empty')
|
||||
}
|
||||
if (!EMAIL_REGEX.test(value.trim())) {
|
||||
throw new Error('Invalid Email')
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
const schema = {
|
||||
"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;
|
||||
},
|
||||
},
|
||||
"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;
|
||||
},
|
||||
},
|
||||
"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;
|
||||
},
|
||||
},
|
||||
"E-mail": {
|
||||
prop: "email",
|
||||
required: true,
|
||||
type: (value: any) => {
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("Email cannot be empty");
|
||||
}
|
||||
if (!EMAIL_REGEX.test(value.trim())) {
|
||||
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)
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content, { schema, ignoreEmptyRows: false }).then(
|
||||
(data) => {
|
||||
setParsedExcel(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedExcel.rows) {
|
||||
const duplicates: ExcelCodegenDuplicatesMap = {
|
||||
email: new Map(),
|
||||
passport_id: new Map(),
|
||||
};
|
||||
const duplicateValues = new Set<string>();
|
||||
const duplicateRowIndices = new Set<number>();
|
||||
useEffect(() => {
|
||||
if (parsedExcel.rows) {
|
||||
const duplicates: ExcelCodegenDuplicatesMap = {
|
||||
email: new Map(),
|
||||
passport_id: new Map(),
|
||||
};
|
||||
const duplicateValues = new Set<string>();
|
||||
const duplicateRowIndices = new Set<number>();
|
||||
|
||||
const errorRowIndices = new Set(
|
||||
parsedExcel.errors?.map(error => error.row) || []
|
||||
);
|
||||
const errorRowIndices = new Set(
|
||||
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 => {
|
||||
if (row !== null) {
|
||||
const value = row[field];
|
||||
if (value) {
|
||||
if (!duplicates[field].has(value)) {
|
||||
duplicates[field].set(value, [index + 2]);
|
||||
} else {
|
||||
const existingRows = duplicates[field].get(value);
|
||||
if (existingRows) {
|
||||
existingRows.push(index + 2);
|
||||
duplicateValues.add(value);
|
||||
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
parsedExcel.rows.forEach((row, index) => {
|
||||
if (!errorRowIndices.has(index + 2)) {
|
||||
(
|
||||
Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
|
||||
).forEach((field) => {
|
||||
if (row !== null) {
|
||||
const value = row[field];
|
||||
if (value) {
|
||||
if (!duplicates[field].has(value)) {
|
||||
duplicates[field].set(value, [index + 2]);
|
||||
} else {
|
||||
const existingRows = duplicates[field].get(value);
|
||||
if (existingRows) {
|
||||
existingRows.push(index + 2);
|
||||
duplicateValues.add(value);
|
||||
existingRows.forEach((rowNum) =>
|
||||
duplicateRowIndices.add(rowNum)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const info = parsedExcel.rows
|
||||
.map((row, index) => {
|
||||
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
|
||||
return undefined;
|
||||
}
|
||||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
|
||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||
return undefined;
|
||||
}
|
||||
const info = parsedExcel.rows
|
||||
.map((row, index) => {
|
||||
if (
|
||||
errorRowIndices.has(index + 2) ||
|
||||
duplicateRowIndices.has(index + 2) ||
|
||||
row === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
studentID,
|
||||
passport_id,
|
||||
email,
|
||||
phone,
|
||||
group,
|
||||
country,
|
||||
} = row;
|
||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
};
|
||||
}).filter((x) => !!x) as typeof infos;
|
||||
return {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
};
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos;
|
||||
|
||||
setInfos(info);
|
||||
}
|
||||
}, [entity, parsedExcel, type]);
|
||||
setInfos(info);
|
||||
}
|
||||
}, [entity, parsedExcel, type]);
|
||||
|
||||
const generateAndInvite = async () => {
|
||||
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 generateAndInvite = async () => {
|
||||
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;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
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?`
|
||||
)
|
||||
)
|
||||
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)!`))
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
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)!`
|
||||
)
|
||||
)
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations.map((info, index) => ({ ...info, code: codes[index] })),
|
||||
expiryDate,
|
||||
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" },
|
||||
);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations.map((info, index) => ({
|
||||
...info,
|
||||
code: codes[index],
|
||||
})),
|
||||
expiryDate,
|
||||
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" }
|
||||
);
|
||||
|
||||
onFinish();
|
||||
return;
|
||||
}
|
||||
onFinish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
|
||||
const handleTemplateDownload = () => {
|
||||
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 handleTemplateDownload = () => {
|
||||
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');
|
||||
link.href = url;
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
link.download = fileName;
|
||||
link.download = fileName;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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="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`} />
|
||||
<h2 className="text-lg font-semibold">
|
||||
The uploaded document must:
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
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:
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
all incorrect e-mails will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
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.
|
||||
</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.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-lg p-4">
|
||||
<p className="text-gray-600">
|
||||
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
|
||||
</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">
|
||||
Close
|
||||
</Button>
|
||||
return (
|
||||
<>
|
||||
<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="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`}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">
|
||||
The uploaded document must:
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
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:
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
all incorrect e-mails will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
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.
|
||||
</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.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-lg p-4">
|
||||
<p className="text-gray-600">
|
||||
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
|
||||
</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"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFileDownload size={24} />
|
||||
Download Template
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</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>
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||
data-tip="Excel File Format"
|
||||
>
|
||||
<IoInformationCircleOutline size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{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}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
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",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
||||
{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) => {
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{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>
|
||||
<CodegenTable infos={infos} />
|
||||
</div>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
Generate & Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={handleTemplateDownload}
|
||||
variant="solid"
|
||||
className="self-end w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFileDownload size={24} />
|
||||
Download Template
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</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>
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||
data-tip="Excel File Format"
|
||||
>
|
||||
<IoInformationCircleOutline size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{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}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
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"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{
|
||||
value: (entities || [])[0]?.id,
|
||||
label: (entities || [])[0]?.label,
|
||||
}}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Select the type of user they should be
|
||||
</label>
|
||||
{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).reduce((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as 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 && (
|
||||
<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>
|
||||
<CodegenTable infos={infos} />
|
||||
</div>
|
||||
)}
|
||||
{checkAccess(
|
||||
user,
|
||||
["developer", "admin", "corporate", "mastercorporate"],
|
||||
permissions,
|
||||
"createCodes"
|
||||
) && (
|
||||
<Button
|
||||
onClick={generateAndInvite}
|
||||
disabled={
|
||||
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||
}
|
||||
>
|
||||
Generate & Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,173 +12,225 @@ 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[] };
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
} = {
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
},
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[]
|
||||
onFinish: () => void;
|
||||
user: User;
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
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,
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
||||
const [expiryDate, setExpiryDate] = useState<Date | 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const code = uid.randomUUID(6);
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const code = uid.randomUUID(6);
|
||||
|
||||
axios
|
||||
.post("/api/code", { type, codes: [code], expiryDate, entity })
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||
toastId: "success",
|
||||
});
|
||||
setGeneratedCode(code);
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.post("/api/code", { type, codes: [code], expiryDate, entity })
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||
toastId: "success",
|
||||
});
|
||||
setGeneratedCode(code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
});
|
||||
};
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
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>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{
|
||||
value: (entities || [])[0]?.id,
|
||||
label: (entities || [])[0]?.label,
|
||||
}}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
<select
|
||||
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) => {
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
<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).reduce<string[]>((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||
acc.push(x);
|
||||
return acc;
|
||||
}, [])}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{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}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
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",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
<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",
|
||||
)}
|
||||
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>}
|
||||
</div>
|
||||
);
|
||||
{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}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
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"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
<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"
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ export default function CorporateGradingSystem({
|
||||
<>
|
||||
<Separator />
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Apply this grading system to other entities
|
||||
Copy this grading system to other entities
|
||||
</label>
|
||||
<Select
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
@@ -282,7 +282,7 @@ export default function CorporateGradingSystem({
|
||||
disabled={isLoading || otherEntities.length === 0}
|
||||
variant="outline"
|
||||
>
|
||||
Apply to {otherEntities.length} other entities
|
||||
Copy to {otherEntities.length} other entities
|
||||
</Button>
|
||||
<Separator />
|
||||
</>
|
||||
@@ -326,7 +326,7 @@ export default function CorporateGradingSystem({
|
||||
disabled={isLoading}
|
||||
className="mt-8"
|
||||
>
|
||||
Save Grading System
|
||||
Save Changes to entities
|
||||
</Button>
|
||||
</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";
|
||||
|
||||
@@ -15,170 +15,210 @@ import { findBy, mapBy } from "@/utils";
|
||||
import useEntitiesCodes from "@/hooks/useEntitiesCodes";
|
||||
import Table from "@/components/High/Table";
|
||||
|
||||
type TableData = Code & { entity?: EntityWithRoles, creator?: User }
|
||||
type TableData = Code & { entity?: EntityWithRoles; creator?: User };
|
||||
const columnHelper = createColumnHelper<TableData>();
|
||||
|
||||
export default function CodeList({ user, entities, canDeleteCodes }
|
||||
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) {
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
export default function CodeList({
|
||||
user,
|
||||
entities,
|
||||
canDeleteCodes,
|
||||
}: {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
canDeleteCodes?: boolean;
|
||||
}) {
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
|
||||
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities])
|
||||
const entityIDs = useMemo(() => mapBy(entities, "id"), [entities]);
|
||||
|
||||
const { users } = useUsers();
|
||||
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs)
|
||||
const { users } = useUsers();
|
||||
const { codes, reload, isLoading } = useEntitiesCodes(
|
||||
isAdmin(user) ? undefined : entityIDs
|
||||
);
|
||||
|
||||
const data: TableData[] = useMemo(() => codes.map((code) => ({
|
||||
...code,
|
||||
entity: findBy(entities, 'id', code.entity),
|
||||
creator: findBy(users, 'id', code.creator)
|
||||
})) as TableData[], [codes, entities, users])
|
||||
const data: TableData[] = useMemo(
|
||||
() =>
|
||||
codes.map((code) => ({
|
||||
...code,
|
||||
entity: findBy(entities, "id", code.entity),
|
||||
creator: findBy(users, "id", code.creator),
|
||||
})) as TableData[],
|
||||
[codes, entities, users]
|
||||
);
|
||||
|
||||
const toggleCode = (id: string) => {
|
||||
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
const toggleCode = (id: string) => {
|
||||
setSelectedCodes((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// const toggleAllCodes = (checked: boolean) => {
|
||||
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
|
||||
// const toggleAllCodes = (checked: boolean) => {
|
||||
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
|
||||
|
||||
// return setSelectedCodes([]);
|
||||
// };
|
||||
// return setSelectedCodes([]);
|
||||
// };
|
||||
|
||||
const deleteCodes = async (codes: string[]) => {
|
||||
if (!canDeleteCodes) return
|
||||
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
|
||||
const deleteCodes = async (codes: string[]) => {
|
||||
if (!canDeleteCodes) return;
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
|
||||
)
|
||||
return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
codes.forEach((code) => params.append("code", code));
|
||||
const params = new URLSearchParams();
|
||||
codes.forEach((code) => params.append("code", code));
|
||||
|
||||
axios
|
||||
.delete(`/api/code?${params.toString()}`)
|
||||
.then(() => {
|
||||
toast.success(`Deleted the codes!`);
|
||||
setSelectedCodes([]);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/code?${params.toString()}`)
|
||||
.then(() => {
|
||||
toast.success(`Deleted the codes!`);
|
||||
setSelectedCodes([]);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const deleteCode = async (code: Code) => {
|
||||
if (!canDeleteCodes) return
|
||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
|
||||
const deleteCode = async (code: Code) => {
|
||||
if (!canDeleteCodes) return;
|
||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
|
||||
return;
|
||||
|
||||
axios
|
||||
.delete(`/api/code/${code.code}`)
|
||||
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/code/${code.code}`)
|
||||
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("code", {
|
||||
id: "codeCheckbox",
|
||||
enableSorting: false,
|
||||
header: () => (""),
|
||||
cell: (info) =>
|
||||
!info.row.original.userId ? (
|
||||
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
|
||||
{""}
|
||||
</Checkbox>
|
||||
) : null,
|
||||
}),
|
||||
columnHelper.accessor("code", {
|
||||
header: "Code",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("creationDate", {
|
||||
header: "Creation Date",
|
||||
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("creator", {
|
||||
header: "Creator",
|
||||
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entity", {
|
||||
header: "Entity",
|
||||
cell: (info) => info.getValue()?.label || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
header: "Availability",
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<span className="flex gap-1 items-center text-mti-green">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center text-mti-red">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Code } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{canDeleteCodes && !row.original.userId && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("code", {
|
||||
id: "codeCheckbox",
|
||||
enableSorting: false,
|
||||
header: () => "",
|
||||
cell: (info) =>
|
||||
!info.row.original.userId ? (
|
||||
<Checkbox
|
||||
isChecked={selectedCodes.includes(info.getValue())}
|
||||
onChange={() => toggleCode(info.getValue())}
|
||||
>
|
||||
{""}
|
||||
</Checkbox>
|
||||
) : null,
|
||||
}),
|
||||
columnHelper.accessor("code", {
|
||||
header: "Code",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("creationDate", {
|
||||
header: "Creation Date",
|
||||
cell: (info) =>
|
||||
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("creator", {
|
||||
header: "Creator",
|
||||
cell: (info) =>
|
||||
info.getValue()
|
||||
? `${info.getValue().name} (${
|
||||
USER_TYPE_LABELS[info.getValue().type]
|
||||
})`
|
||||
: "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entity", {
|
||||
header: "Entity",
|
||||
cell: (info) => info.getValue()?.label || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
header: "Availability",
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<span className="flex gap-1 items-center text-mti-green">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center text-mti-red">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Code } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{canDeleteCodes && !row.original.userId && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deleteCode(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-4 pt-1">
|
||||
{canDeleteCodes && (
|
||||
<div className="flex gap-4 items-center w-full justify-end">
|
||||
<span>{selectedCodes.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedCodes.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteCodes(selectedCodes)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table<TableData>
|
||||
data={data}
|
||||
columns={defaultColumns}
|
||||
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-4 pt-1">
|
||||
{canDeleteCodes && (
|
||||
<div className="flex gap-4 items-center w-full justify-end">
|
||||
<span>{selectedCodes.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedCodes.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteCodes(selectedCodes)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table<TableData>
|
||||
data={data}
|
||||
columns={defaultColumns}
|
||||
isLoading={isLoading}
|
||||
searchFields={[
|
||||
["code"],
|
||||
["email"],
|
||||
["entity", "label"],
|
||||
["creator", "name"],
|
||||
["creator", "type"],
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,287 +1,394 @@
|
||||
import {useMemo, useState} from "react";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import { useMemo, useState } from "react";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import useExams from "@/hooks/useExams";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {User} from "@/interfaces/user";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import {capitalize, uniq} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsBan, BsCheck, BsCircle, BsPencil, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {useListSearch} from "@/hooks/useListSearch";
|
||||
import { capitalize } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsPencil, BsTrash, BsUpload } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import Modal from "@/components/Modal";
|
||||
import {checkAccess} from "@/utils/permissions";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {EntityWithRoles} from "@/interfaces/entity";
|
||||
import {BiEdit} from "react-icons/bi";
|
||||
import {findBy, mapBy} from "@/utils";
|
||||
import {getUserName} from "@/utils/users";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { BiEdit } from "react-icons/bi";
|
||||
import { findBy, mapBy } from "@/utils";
|
||||
|
||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||
|
||||
const CLASSES: {[key in Module]: string} = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
const CLASSES: { [key in Module]: string } = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Exam>();
|
||||
|
||||
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) {
|
||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||
export default function ExamList({
|
||||
user,
|
||||
entities,
|
||||
}: {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
}) {
|
||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||
|
||||
const {exams, reload} = useExams();
|
||||
const {users} = useUsers();
|
||||
const canViewConfidentialEntities = useMemo(
|
||||
() =>
|
||||
mapBy(
|
||||
findAllowedEntities(user, entities, "view_confidential_exams"),
|
||||
"id"
|
||||
),
|
||||
[user, entities]
|
||||
);
|
||||
|
||||
const filteredExams = useMemo(
|
||||
() =>
|
||||
exams.filter((e) => {
|
||||
if (!e.private) return true;
|
||||
return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent));
|
||||
}),
|
||||
[exams, user?.entities],
|
||||
);
|
||||
const { exams, reload, isLoading } = useExams();
|
||||
const { users } = useUsers();
|
||||
// Pass this permission filter to the backend later
|
||||
const filteredExams = useMemo(
|
||||
() =>
|
||||
["admin", "developer"].includes(user?.type)
|
||||
? exams
|
||||
: exams.filter((item) => {
|
||||
if (
|
||||
item.access === "confidential" &&
|
||||
!canViewConfidentialEntities.find((x) =>
|
||||
(item.entities ?? []).includes(x)
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
[canViewConfidentialEntities, exams, user?.type]
|
||||
);
|
||||
|
||||
const parsedExams = useMemo(() => {
|
||||
return filteredExams.map((exam) => {
|
||||
if (exam.createdBy) {
|
||||
const user = users.find((u) => u.id === exam.createdBy);
|
||||
if (!user) return exam;
|
||||
const parsedExams = useMemo(() => {
|
||||
return filteredExams.map((exam) => {
|
||||
if (exam.createdBy) {
|
||||
const user = users.find((u) => u.id === exam.createdBy);
|
||||
if (!user) return exam;
|
||||
|
||||
return {
|
||||
...exam,
|
||||
createdBy: user.type === "developer" ? "system" : user.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...exam,
|
||||
createdBy: user.type === "developer" ? "system" : user.name,
|
||||
};
|
||||
}
|
||||
|
||||
return exam;
|
||||
});
|
||||
}, [filteredExams, users]);
|
||||
return exam;
|
||||
});
|
||||
}, [filteredExams, users]);
|
||||
|
||||
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
|
||||
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
|
||||
searchFields,
|
||||
parsedExams
|
||||
);
|
||||
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const loadExam = async (module: Module, examId: string) => {
|
||||
const exam = await getExamById(module, 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",
|
||||
});
|
||||
const loadExam = async (module: Module, examId: string) => {
|
||||
const exam = await getExamById(module, 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",
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}});
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: { exams: [exam], modules: [module] },
|
||||
});
|
||||
|
||||
router.push("/exam");
|
||||
};
|
||||
router.push("/exam");
|
||||
};
|
||||
|
||||
const privatizeExam = async (exam: Exam) => {
|
||||
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
||||
/*
|
||||
const privatizeExam = async (exam: Exam) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
|
||||
exam.access
|
||||
}?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
axios
|
||||
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private})
|
||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to update this exam!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to update this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
*/
|
||||
|
||||
const deleteExam = async (exam: Exam) => {
|
||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||
const deleteExam = async (exam: Exam) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
axios
|
||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const getTotalExercises = (exam: Exam) => {
|
||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||
}
|
||||
const getTotalExercises = (exam: Exam) => {
|
||||
if (
|
||||
exam.module === "reading" ||
|
||||
exam.module === "listening" ||
|
||||
exam.module === "level"
|
||||
) {
|
||||
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
|
||||
}
|
||||
|
||||
return countExercises(exam.exercises);
|
||||
};
|
||||
return countExercises(exam.exercises);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("module", {
|
||||
header: "Module",
|
||||
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
||||
}),
|
||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||
header: "Exercises",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("minTimer", {
|
||||
header: "Timer",
|
||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||
}),
|
||||
columnHelper.accessor("private", {
|
||||
header: "Private",
|
||||
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
header: "Created At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (value) {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("module", {
|
||||
header: "Module",
|
||||
cell: (info) => (
|
||||
<span className={CLASSES[info.getValue()]}>
|
||||
{capitalize(info.getValue())}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||
header: "Exercises",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("minTimer", {
|
||||
header: "Timer",
|
||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||
}),
|
||||
columnHelper.accessor("access", {
|
||||
header: "Access",
|
||||
cell: (info) => <span>{capitalize(info.getValue())}</span>,
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
header: "Created At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (value) {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdBy", {
|
||||
header: "Created By",
|
||||
cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Exam}}) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
||||
<>
|
||||
<button
|
||||
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
||||
onClick={async () => await privatizeExam(row.original)}
|
||||
className="cursor-pointer tooltip">
|
||||
{row.original.private ? <BsCircle /> : <BsBan />}
|
||||
</button>
|
||||
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
|
||||
<button data-tip="Edit exam" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
|
||||
<BsPencil />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
data-tip="Load exam"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdBy", {
|
||||
header: "Created By",
|
||||
cell: (info) =>
|
||||
!info.getValue()
|
||||
? "System"
|
||||
: findBy(users, "id", info.getValue())?.name || "N/A",
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Exam } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{(row.original.owners?.includes(user.id) ||
|
||||
checkAccess(user, ["admin", "developer"])) && (
|
||||
<>
|
||||
{checkAccess(user, [
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<button
|
||||
data-tip="Edit exam"
|
||||
onClick={() => setSelectedExam(row.original)}
|
||||
className="cursor-pointer tooltip"
|
||||
>
|
||||
<BsPencil />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
data-tip="Load exam"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={async () =>
|
||||
await loadExam(row.original.module, row.original.id)
|
||||
}
|
||||
>
|
||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deleteExam(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredRows,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: filteredRows,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const handleExamEdit = () => {
|
||||
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
|
||||
};
|
||||
const handleExamEdit = () => {
|
||||
router.push(
|
||||
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
{renderSearch()}
|
||||
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl">
|
||||
{!!selectedExam ? (
|
||||
<>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BiEdit className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-gray-600 font-medium">Ready to Edit</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
{renderSearch()}
|
||||
<Modal
|
||||
isOpen={!!selectedExam}
|
||||
onClose={() => setSelectedExam(undefined)}
|
||||
maxWidth="max-w-xl"
|
||||
>
|
||||
{!!selectedExam ? (
|
||||
<>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BiEdit className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-gray-600 font-medium">
|
||||
Ready to Edit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm">Click 'Next' to proceed to the exam editor.</p>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Click 'Next' to proceed to the exam editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-4 mt-8">
|
||||
<Button color="purple" variant="outline" onClick={() => setSelectedExam(undefined)} className="w-32">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" onClick={handleExamEdit} className="w-32 text-white flex items-center justify-center gap-2">
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<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())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
<div className="flex justify-between gap-4 mt-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedExam(undefined)}
|
||||
className="w-32"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={handleExamEdit}
|
||||
className="w-32 text-white flex items-center justify-center gap-2"
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<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()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading ? (
|
||||
<div className="min-h-screen flex justify-center items-start">
|
||||
<span className="loading loading-infinity w-32" />
|
||||
</div>
|
||||
) : (
|
||||
filteredRows.length === 0 && (
|
||||
<div className="w-full flex justify-center items-start">
|
||||
<span className="text-xl text-gray-500">No data found...</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,23 +308,30 @@ 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 && (
|
||||
<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)}>
|
||||
, View More
|
||||
</button>
|
||||
)}
|
||||
{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)}>
|
||||
, View Less
|
||||
</button>
|
||||
)}
|
||||
{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)}
|
||||
>
|
||||
, View More
|
||||
</button>
|
||||
)}
|
||||
{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)}
|
||||
>
|
||||
, View Less
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
@@ -252,20 +341,34 @@ export default function GroupList({ user }: { user: User }) {
|
||||
cell: ({ row }: { row: { original: Group } }) => {
|
||||
return (
|
||||
<>
|
||||
{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)}>
|
||||
<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)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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)}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,256 +1,341 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
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 { Module } from "@/interfaces";
|
||||
import { Package } from "@/interfaces/paypal";
|
||||
import { User } from "@/interfaces/user";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import {capitalize} from "lodash";
|
||||
import {useState} from "react";
|
||||
import {BsPencil, BsTrash} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import { capitalize } from "lodash";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import Select from "react-select";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
import { CURRENCIES } from "@/resources/paypal";
|
||||
import Button from "@/components/Low/Button";
|
||||
|
||||
const CLASSES: {[key in Module]: string} = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
const CLASSES: { [key in Module]: string } = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Package>();
|
||||
|
||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||
|
||||
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 currencyOptions = CURRENCIES.map(({ label, currency }) => ({
|
||||
value: currency,
|
||||
label,
|
||||
}));
|
||||
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||
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 submit = () => {
|
||||
(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();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
};
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||
|
||||
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>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
||||
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();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
}, [duration, unit, price, currency, pack, onClose]);
|
||||
|
||||
<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"}}
|
||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<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))} />
|
||||
<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={[
|
||||
{value: "days", label: "Days"},
|
||||
{value: "weeks", label: "Weeks"},
|
||||
{value: "months", label: "Months"},
|
||||
{value: "years", label: "Years"},
|
||||
]}
|
||||
defaultValue={{value: "months", label: "Months"}}
|
||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
||||
value={{value: unit, label: capitalize(unit)}}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</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}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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>
|
||||
<div className="flex gap-4 items-center">
|
||||
<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={currencyOptions}
|
||||
defaultValue={currencyDefaultValue}
|
||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||
value={currencyDefaultValue}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<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))}
|
||||
/>
|
||||
<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={[
|
||||
{ value: "days", label: "Days" },
|
||||
{ value: "weeks", label: "Weeks" },
|
||||
{ value: "months", label: "Months" },
|
||||
{ value: "years", label: "Years" },
|
||||
]}
|
||||
defaultValue={{ value: "months", label: "Months" }}
|
||||
onChange={(value) =>
|
||||
setUnit((value?.value as DurationUnit) || "months")
|
||||
}
|
||||
value={{ value: unit, label: capitalize(unit) }}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</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}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
disabled={!duration || !price}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackageList({user}: {user: User}) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||
export default function PackageList({ user }: { user: User }) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||
|
||||
const {packages, reload} = usePackages();
|
||||
const { packages, reload } = usePackages();
|
||||
|
||||
const deletePackage = async (pack: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||
const deletePackage = useCallback(
|
||||
async (pack: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/packages/${pack.id}`)
|
||||
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Package not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/packages/${pack.id}`)
|
||||
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Package not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("duration", {
|
||||
header: "Duration",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.duration_unit}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("price", {
|
||||
header: "Price",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.currency}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
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)}>
|
||||
<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)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("duration", {
|
||||
header: "Duration",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.duration_unit}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("price", {
|
||||
header: "Price",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.currency}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
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)}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[deletePackage, user]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: packages,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: packages,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingPackage(undefined);
|
||||
reload();
|
||||
};
|
||||
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"}>
|
||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<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())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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">
|
||||
New Package
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingPackage}
|
||||
onClose={closeModal}
|
||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
|
||||
>
|
||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<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()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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"
|
||||
>
|
||||
New Package
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +1,158 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {Stat, StudentUser, User} from "@/interfaces/user";
|
||||
import {useState} from "react";
|
||||
import {averageLevelCalculator} from "@/utils/score";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import {createColumnHelper} from "@tanstack/react-table";
|
||||
import { Stat, StudentUser, User } from "@/interfaces/user";
|
||||
import { useState } from "react";
|
||||
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 [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||
const StudentPerformanceList = ({
|
||||
items = [],
|
||||
}: {
|
||||
items: StudentPerformanceItem[];
|
||||
}) => {
|
||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||
|
||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Student Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("studentID", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("group", {
|
||||
header: "Group",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("entitiesLabel", {
|
||||
header: "Entities",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("levels.reading", {
|
||||
header: "Reading",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).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`,
|
||||
}),
|
||||
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`,
|
||||
}),
|
||||
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`,
|
||||
}),
|
||||
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`,
|
||||
}),
|
||||
columnHelper.accessor("levels", {
|
||||
id: "overall_level",
|
||||
header: "Overall",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? averageLevelCalculator(
|
||||
items,
|
||||
stats.filter((x) => x.user === info.row.original.id),
|
||||
).toFixed(1)
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||
}),
|
||||
];
|
||||
const columns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Student Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("studentID", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("group", {
|
||||
header: "Group",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("entitiesLabel", {
|
||||
header: "Entities",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("levels.reading", {
|
||||
header: "Reading",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "level"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("userStats", {
|
||||
id: "overall_level",
|
||||
header: "Overall",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? averageLevelCalculator(
|
||||
info.row.original.focus,
|
||||
info.getValue()
|
||||
).toFixed(1)
|
||||
: `${Object.keys(groupByExam(info.getValue())).length} exams`,
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||
Show Utilization
|
||||
</Checkbox>
|
||||
<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),
|
||||
),
|
||||
)}
|
||||
columns={columns}
|
||||
searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||
Show Utilization
|
||||
</Checkbox>
|
||||
<Table<StudentPerformanceItem>
|
||||
data={items.sort(
|
||||
(a, b) =>
|
||||
averageLevelCalculator(b.focus, b.userStats) -
|
||||
averageLevelCalculator(a.focus, a.userStats)
|
||||
)}
|
||||
columns={columns}
|
||||
searchFields={[
|
||||
["name"],
|
||||
["email"],
|
||||
["studentID"],
|
||||
["entitiesLabel"],
|
||||
["group"],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentPerformanceList;
|
||||
|
||||
@@ -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,
|
||||
@@ -22,8 +22,6 @@ import useFilterStore from "@/stores/listFilterStore";
|
||||
import { useRouter } from "next/router";
|
||||
import { mapBy } from "@/utils";
|
||||
import { exportListToExcel } from "@/utils/users";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import useUserBalance from "@/hooks/useUserBalance";
|
||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||
import Table from "@/components/High/Table";
|
||||
@@ -494,21 +492,19 @@ export default function UserList({
|
||||
},
|
||||
];
|
||||
|
||||
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
|
||||
const downloadExcel = async (rows: WithLabeledEntities<User>[]) => {
|
||||
if (entitiesDownloadUsers.length === 0)
|
||||
return toast.error("You are not allowed to download the user list.");
|
||||
|
||||
const allowedRows = rows.filter((r) =>
|
||||
mapBy(r.entities, "id").some((e) =>
|
||||
mapBy(entitiesDownloadUsers, "id").includes(e)
|
||||
)
|
||||
);
|
||||
const csv = exportListToExcel(allowedRows);
|
||||
const allowedRows = rows;
|
||||
const csv = await exportListToExcel(allowedRows);
|
||||
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([csv], { type: "text/csv" });
|
||||
const file = new Blob([csv], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = "users.csv";
|
||||
element.download = "users.xlsx";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
|
||||
@@ -4,7 +4,6 @@ import clsx from "clsx";
|
||||
import CodeList from "./CodeList";
|
||||
import DiscountList from "./DiscountList";
|
||||
import ExamList from "./ExamList";
|
||||
import GroupList from "./GroupList";
|
||||
import PackageList from "./PackageList";
|
||||
import UserList from "./UserList";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
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";
|
||||
@@ -48,23 +41,44 @@ 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;
|
||||
users: User[];
|
||||
entities: EntityWithRoles[]
|
||||
entities: EntityWithRoles[];
|
||||
permissions: PermissionType[];
|
||||
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>();
|
||||
@@ -75,13 +89,15 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
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);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [position, setPosition] = useState<string>();
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||
|
||||
const { groups } = useEntitiesGroups();
|
||||
|
||||
@@ -90,11 +106,16 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
}, [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);
|
||||
|
||||
@@ -128,8 +149,12 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
setStudentID("");
|
||||
setCountry(user?.demographicInformation?.country);
|
||||
setGroup(null);
|
||||
setEntity((entities || [])[0]?.id || undefined)
|
||||
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
|
||||
setEntity((entities || [])[0]?.id || undefined);
|
||||
setExpiryDate(
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user?.subscriptionExpirationDate).toDate()
|
||||
: null
|
||||
);
|
||||
setIsExpiryDateEnabled(true);
|
||||
setType("student");
|
||||
setPosition(undefined);
|
||||
@@ -145,10 +170,34 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
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"
|
||||
@@ -160,11 +209,21 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
/>
|
||||
|
||||
<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" && (
|
||||
<>
|
||||
@@ -177,14 +236,26 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
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"])}
|
||||
@@ -192,11 +263,20 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
</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)
|
||||
@@ -209,63 +289,85 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
<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) => {
|
||||
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>
|
||||
))}
|
||||
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];
|
||||
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"]) && (
|
||||
<>
|
||||
<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}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
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",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
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"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user?.subscriptionExpirationDate
|
||||
? moment(date).isBefore(
|
||||
user?.subscriptionExpirationDate
|
||||
)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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);
|
||||
|
||||
@@ -13,267 +13,268 @@ import moment from "moment";
|
||||
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
}
|
||||
|
||||
const availableDurations = {
|
||||
"1_month": { label: "1 Month", number: 1 },
|
||||
"3_months": { label: "3 Months", number: 3 },
|
||||
"6_months": { label: "6 Months", number: 6 },
|
||||
"12_months": { label: "12 Months", number: 12 },
|
||||
"1_month": { label: "1 Month", number: 1 },
|
||||
"3_months": { label: "3 Months", number: 3 },
|
||||
"6_months": { label: "6 Months", number: 6 },
|
||||
"12_months": { label: "12 Months", number: 12 },
|
||||
};
|
||||
|
||||
export default function RegisterCorporate({
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
mutateUser,
|
||||
sendEmailVerification,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
mutateUser,
|
||||
sendEmailVerification,
|
||||
}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
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!",
|
||||
);
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
"An e-mail has been sent, please make sure to check your spam folder!"
|
||||
);
|
||||
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {
|
||||
toastId: "send-verify-error",
|
||||
});
|
||||
};
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {
|
||||
toastId: "send-verify-error",
|
||||
});
|
||||
};
|
||||
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {
|
||||
toastId: "password-not-match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {
|
||||
toastId: "password-not-match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "corporate",
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||
corporateInformation: {
|
||||
monthlyDuration: subscriptionDuration,
|
||||
referralAgent,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() =>
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "corporate",
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||
corporateInformation: {
|
||||
monthlyDuration: subscriptionDuration,
|
||||
referralAgent,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() =>
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-4"
|
||||
onSubmit={register}
|
||||
>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={(e) => setName(e)}
|
||||
placeholder="Enter your name"
|
||||
defaultValue={name}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e.toLowerCase())}
|
||||
placeholder="Enter email address"
|
||||
defaultValue={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-4"
|
||||
onSubmit={register}
|
||||
>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={(e) => setName(e)}
|
||||
placeholder="Enter your name"
|
||||
defaultValue={name}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e.toLowerCase())}
|
||||
placeholder="Enter email address"
|
||||
defaultValue={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider className="!my-2 w-full" />
|
||||
<Divider className="!my-2 w-full" />
|
||||
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Corporate name"
|
||||
label="Corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
label="Number of users"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Corporate name"
|
||||
label="Corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
label="Number of users"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Referral *
|
||||
</label>
|
||||
<Select
|
||||
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}` })),
|
||||
]}
|
||||
defaultValue={{ value: "", label: "No referral" }}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Referral *
|
||||
</label>
|
||||
<Select
|
||||
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.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
})),
|
||||
]}
|
||||
defaultValue={{ value: "", label: "No referral" }}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Subscription Duration *
|
||||
</label>
|
||||
<Select
|
||||
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={Object.keys(availableDurations).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
availableDurations[value as keyof typeof availableDurations]
|
||||
.label,
|
||||
}))}
|
||||
defaultValue={{
|
||||
value: "1_month",
|
||||
label: availableDurations["1_month"].label,
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setSubscriptionDuration(
|
||||
value
|
||||
? availableDurations[
|
||||
value.value as keyof typeof availableDurations
|
||||
].number
|
||||
: 1,
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!email ||
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
password !== confirmPassword ||
|
||||
!companyName ||
|
||||
companyUsers <= 0
|
||||
}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Subscription Duration *
|
||||
</label>
|
||||
<Select
|
||||
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={Object.keys(availableDurations).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
availableDurations[value as keyof typeof availableDurations]
|
||||
.label,
|
||||
}))}
|
||||
defaultValue={{
|
||||
value: "1_month",
|
||||
label: availableDurations["1_month"].label,
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setSubscriptionDuration(
|
||||
value
|
||||
? availableDurations[
|
||||
value.value as keyof typeof availableDurations
|
||||
].number
|
||||
: 1
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!email ||
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
password !== confirmPassword ||
|
||||
!companyName ||
|
||||
companyUsers <= 0
|
||||
}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,5 +19,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows"));
|
||||
const entityIdsString = req.query.entityIds as string;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Code, Group, Type } from "@/interfaces/user";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { Code, } from "@/interfaces/user";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { doesEntityAllow } from "@/utils/permissions";
|
||||
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { findBy } from "@/utils";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
@@ -30,7 +22,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const { entities } = req.query as { entities?: string[] };
|
||||
if (entities)
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: entities } }).toArray());
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: Array.isArray(entities) ? entities : [entities] } }).toArray());
|
||||
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({}).toArray());
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
const { entity } = req.query as { entity?: string };
|
||||
|
||||
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
|
||||
|
||||
res.status(200).json(snapshot);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 { createApprovalWorkflowsOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
|
||||
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";
|
||||
import { mapBy } from "@/utils";
|
||||
@@ -10,6 +10,8 @@ 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";
|
||||
|
||||
@@ -17,127 +19,161 @@ const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await GET(req, res);
|
||||
if (req.method === "POST") return await POST(req, res);
|
||||
// Temporary: Adding UUID here but later move to backend.
|
||||
function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
|
||||
const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
|
||||
|
||||
res.status(404).json({ ok: false });
|
||||
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);
|
||||
|
||||
res.status(404).json({ ok: false });
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, avoidRepeated, variant, instructorGender } = req.query as {
|
||||
module: Module;
|
||||
avoidRepeated: string;
|
||||
variant?: Variant;
|
||||
instructorGender?: InstructorGender;
|
||||
};
|
||||
const { module, avoidRepeated, variant, instructorGender } = req.query as {
|
||||
module: Module;
|
||||
avoidRepeated: string;
|
||||
variant?: Variant;
|
||||
instructorGender?: InstructorGender;
|
||||
};
|
||||
|
||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
|
||||
res.status(200).json(exams);
|
||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
|
||||
res.status(200).json(exams);
|
||||
}
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return res.status(401).json({ ok: false });
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return res.status(401).json({ ok: false });
|
||||
|
||||
const { module } = req.query as { module: string };
|
||||
const { module } = req.query as { module: string };
|
||||
|
||||
const session = client.startSession();
|
||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); // might need to change this with new approval workflows logic.. if an admin creates an exam no workflow is started because workflows must have entities configured.
|
||||
const session = client.startSession();
|
||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
|
||||
|
||||
try {
|
||||
const exam = {
|
||||
...req.body,
|
||||
module: module,
|
||||
entities,
|
||||
createdBy: user.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
let exam = {
|
||||
access: "public", // default access is public
|
||||
...req.body,
|
||||
module: module,
|
||||
entities,
|
||||
createdBy: user.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let responseStatus: number;
|
||||
let responseMessage: string;
|
||||
// Temporary: Adding UUID here but later move to backend.
|
||||
exam = addUUIDs(exam);
|
||||
|
||||
await session.withTransaction(async () => {
|
||||
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
||||
let responseStatus: number;
|
||||
let responseMessage: string;
|
||||
|
||||
// Check whether the id of the exam matches another exam with different
|
||||
// owners, throw exception if there is, else allow editing
|
||||
const existingExamOwners = docSnap?.owners ?? [];
|
||||
const newExamOwners = exam.owners ?? [];
|
||||
await session.withTransaction(async () => {
|
||||
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
||||
|
||||
const ownersSet = new Set(existingExamOwners);
|
||||
// Check whether the id of the exam matches another exam with different
|
||||
// owners, throw exception if there is, else allow editing
|
||||
const existingExamOwners = docSnap?.owners ?? [];
|
||||
const newExamOwners = exam.owners ?? [];
|
||||
|
||||
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
|
||||
throw new Error("Name already exists");
|
||||
}
|
||||
const ownersSet = new Set(existingExamOwners);
|
||||
|
||||
await db.collection(module).updateOne(
|
||||
{ id: req.body.id },
|
||||
{ $set: { id: req.body.id, ...exam } },
|
||||
{
|
||||
upsert: true,
|
||||
session,
|
||||
}
|
||||
);
|
||||
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
|
||||
throw new Error("Name already exists");
|
||||
}
|
||||
|
||||
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response.
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
|
||||
// TODO maybe find a way to start missing approval workflows in case they were only configured after exam creation.
|
||||
if (exam.requiresApproval === true) {
|
||||
exam.access = "confidential";
|
||||
}
|
||||
|
||||
// create workflow only if exam is being created for the first time
|
||||
if (docSnap === null) {
|
||||
try {
|
||||
const { successCount, totalCount } = await createApprovalWorkflowsOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
|
||||
await db.collection(module).updateOne(
|
||||
{ id: req.body.id },
|
||||
{ $set: { id: req.body.id, ...exam } },
|
||||
{
|
||||
upsert: true,
|
||||
session,
|
||||
}
|
||||
);
|
||||
|
||||
if (successCount === totalCount) {
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s)`;
|
||||
} else if (successCount > 0) {
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities`;
|
||||
} else {
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to find any configured Approval Workflow for the author.`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Workflow creation error:", error);
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
|
||||
}
|
||||
} else { // if exam was updated, log the updates
|
||||
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
|
||||
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response.
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
|
||||
|
||||
if (approvalWorkflows) {
|
||||
const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
|
||||
if (differences) {
|
||||
approvalWorkflows.forEach((workflow) => {
|
||||
const currentStepIndex = workflow.steps.findIndex(step => !step.completed || step.rejected);
|
||||
// create workflow only if exam is being created for the first time
|
||||
if (docSnap === null) {
|
||||
try {
|
||||
if (exam.requiresApproval === false) {
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`;
|
||||
} else if (isAdmin(user)) {
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`;
|
||||
} else {
|
||||
const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
|
||||
|
||||
if (workflow.steps[currentStepIndex].examChanges === undefined) {
|
||||
workflow.steps[currentStepIndex].examChanges = [...differences];
|
||||
} else {
|
||||
workflow.steps[currentStepIndex].examChanges!.push(...differences);
|
||||
}
|
||||
});
|
||||
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (successCount === totalCount) {
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
|
||||
} else if (successCount > 0) {
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`;
|
||||
} else {
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Workflow creation error:", error);
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
|
||||
}
|
||||
} else {
|
||||
// if exam was updated, log the updates
|
||||
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
|
||||
|
||||
res.status(responseStatus).json({
|
||||
message: responseMessage,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Transaction failed: ", error);
|
||||
res.status(500).json({ ok: false, error: (error as any).message });
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
if (approvalWorkflows) {
|
||||
const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
|
||||
if (differences) {
|
||||
approvalWorkflows.forEach((workflow) => {
|
||||
const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected);
|
||||
|
||||
if (workflow.steps[currentStepIndex].examChanges === undefined) {
|
||||
workflow.steps[currentStepIndex].examChanges = [...differences];
|
||||
} else {
|
||||
workflow.steps[currentStepIndex].examChanges!.push(...differences);
|
||||
}
|
||||
});
|
||||
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(responseStatus).json({
|
||||
message: responseMessage,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Transaction failed: ", error);
|
||||
res.status(500).json({ ok: false, error: (error as any).message });
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {flatten} from "lodash";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { flatten } from "lodash";
|
||||
import { AccessType, Exam } from "@/interfaces/exam";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { requestUser } from "../../../utils/api";
|
||||
import { mapBy } from "../../../utils";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
@@ -14,17 +16,37 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await GET(req, res);
|
||||
|
||||
res.status(404).json({ok: false});
|
||||
res.status(404).json({ ok: false });
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
const user = await requestUser(req, res)
|
||||
if (!user)
|
||||
return res.status(401).json({ ok: false, reason: "You must be logged in!" })
|
||||
const isAdmin = ["admin", "developer"].includes(user.type)
|
||||
const { entities = [] } = req.query as { access?: AccessType, entities?: string[] | string };
|
||||
let entitiesToFetch = Array.isArray(entities) ? entities : entities ? [entities] : []
|
||||
|
||||
if (!isAdmin) {
|
||||
const userEntitiesIDs = mapBy(user.entities || [], 'id')
|
||||
entitiesToFetch = entities ? entitiesToFetch.filter((entity): entity is string => entity ? userEntitiesIDs.includes(entity) : false) : userEntitiesIDs
|
||||
if ((entitiesToFetch.length ?? 0) === 0) {
|
||||
res.status(200).json([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
|
||||
const snapshot = await db.collection(module).find<Exam>({ isDiagnostic: false }).toArray();
|
||||
const snapshot = await db.collection(module).find<Exam>({
|
||||
isDiagnostic: false, ...(isAdmin && (entitiesToFetch.length ?? 0) === 0 ? {
|
||||
} : {
|
||||
entity: { $in: entitiesToFetch }
|
||||
})
|
||||
}).toArray();
|
||||
|
||||
return snapshot.map((doc) => ({
|
||||
...doc,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
|
||||
const allAssigneeIds: string[] = [
|
||||
...new Set(
|
||||
workflow.steps
|
||||
.map(step => step.assignees)
|
||||
.map((step) => {
|
||||
const assignees = step.assignees;
|
||||
if (step.completedBy) {
|
||||
assignees.push(step.completedBy);
|
||||
}
|
||||
return assignees;
|
||||
})
|
||||
.flat()
|
||||
)
|
||||
];
|
||||
@@ -144,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 approve the exam.`)) return;
|
||||
if (!confirm(`Are you sure you want to approve the last step and complete the approval process?`)) return;
|
||||
}
|
||||
|
||||
const updatedWorkflow: ApprovalWorkflow = {
|
||||
@@ -186,7 +192,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
const examId = currentWorkflow.examId;
|
||||
|
||||
axios
|
||||
.patch(`/api/exam/${examModule}/${examId}`, { isDiagnostic: false })
|
||||
.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) {
|
||||
@@ -254,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;
|
||||
}
|
||||
@@ -383,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"
|
||||
@@ -548,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="text-sm text-gray-500 mb-2">
|
||||
{change}
|
||||
</p>
|
||||
<>
|
||||
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
||||
<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>
|
||||
@@ -570,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
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import Tip from "@/components/ApprovalWorkflows/Tip";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Button from "@/components/Low/Button";
|
||||
import Input from "@/components/Low/Input";
|
||||
import Select from "@/components/Low/Select";
|
||||
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
|
||||
import { useAllowedEntities, useAllowedEntitiesSomePermissions, useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||
import { Module, ModuleTypeLabels } from "@/interfaces";
|
||||
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow";
|
||||
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
|
||||
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { getSpecificUsers } from "@/utils/users.be";
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable, getPaginationRowModel } from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
@@ -69,7 +67,11 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
|
||||
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) return redirect("/");
|
||||
|
||||
const workflows = await getApprovalWorkflows("active-workflows");
|
||||
const entityIDS = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
|
||||
|
||||
const workflows = await getApprovalWorkflows("active-workflows", allowedEntities.map(entity => entity.id));
|
||||
|
||||
const allAssigneeIds: string[] = [
|
||||
...new Set(
|
||||
@@ -81,10 +83,6 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
)
|
||||
];
|
||||
|
||||
const entityIDS = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
|
||||
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
@@ -103,7 +101,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) {
|
||||
const { workflows, reload } = useApprovalWorkflows();
|
||||
const entitiesString = userEntitiesWithLabel.map(entity => entity.id).join(",");
|
||||
const { workflows, reload } = useApprovalWorkflows(entitiesString);
|
||||
const currentWorkflows = workflows || initialWorkflows;
|
||||
|
||||
const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]);
|
||||
@@ -191,7 +190,15 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
|
||||
{info.getValue().map((module: Module, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900">
|
||||
/* className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900"> */
|
||||
className={clsx("inline-block rounded-full px-3 py-1 text-sm font-medium text-white",
|
||||
module === "speaking" ? "bg-ielts-speaking" :
|
||||
module === "reading" ? "bg-ielts-reading" :
|
||||
module === "writing" ? "bg-ielts-writing" :
|
||||
module === "listening" ? "bg-ielts-listening" :
|
||||
module === "level" ? "bg-ielts-level" :
|
||||
"bg-slate-700"
|
||||
)}>
|
||||
{ModuleTypeLabels[module]}
|
||||
</span>
|
||||
))}
|
||||
@@ -296,10 +303,20 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
|
||||
}),
|
||||
];
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredWorkflows,
|
||||
columns: columns,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -395,6 +412,43 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 flex flex-row gap-2 w-full justify-end items-center">
|
||||
<button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{"<<"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{"<"}
|
||||
</button>
|
||||
<span className="px-4 text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{">"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{">>"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,6 @@ import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { generate } from "random-words";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {
|
||||
|
||||
@@ -63,26 +63,26 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const [users, groups] = await Promise.all([
|
||||
isAdmin(user)
|
||||
? getUsers(
|
||||
{},
|
||||
0,
|
||||
{},
|
||||
{
|
||||
_id: 0,
|
||||
id: 1,
|
||||
type: 1,
|
||||
name: 1,
|
||||
email: 1,
|
||||
levels: 1,
|
||||
}
|
||||
)
|
||||
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
|
||||
{},
|
||||
0,
|
||||
{},
|
||||
{
|
||||
_id: 0,
|
||||
id: 1,
|
||||
type: 1,
|
||||
name: 1,
|
||||
email: 1,
|
||||
levels: 1,
|
||||
}),
|
||||
}
|
||||
)
|
||||
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
|
||||
_id: 0,
|
||||
id: 1,
|
||||
type: 1,
|
||||
name: 1,
|
||||
email: 1,
|
||||
levels: 1,
|
||||
}),
|
||||
isAdmin(user)
|
||||
? getGroups()
|
||||
: getGroupsByEntities(mapBy(allowedEntities, "id")),
|
||||
@@ -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();
|
||||
|
||||
@@ -326,7 +329,7 @@ export default function AssignmentsPage({
|
||||
onClick={
|
||||
(!selectedModules.includes("level") &&
|
||||
selectedModules.length === 0) ||
|
||||
selectedModules.includes("level")
|
||||
selectedModules.includes("level")
|
||||
? () => toggleModule("level")
|
||||
: undefined
|
||||
}
|
||||
@@ -501,37 +504,64 @@ export default function AssignmentsPage({
|
||||
Random Exams
|
||||
</Checkbox>
|
||||
{!useRandomExams && (
|
||||
<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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{capitalize(module)} Exam
|
||||
</label>
|
||||
<Select
|
||||
value={{
|
||||
value:
|
||||
examIDs.find((e) => e.module === module)?.id ||
|
||||
null,
|
||||
label:
|
||||
examIDs.find((e) => e.module === module)?.id || "",
|
||||
}}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setExamIDs((prev) => [
|
||||
<>
|
||||
<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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{capitalize(module)} Exam
|
||||
</label>
|
||||
<Select
|
||||
isClearable
|
||||
value={{
|
||||
value:
|
||||
examIDs.find((e) => e.module === module)?.id ||
|
||||
null,
|
||||
label:
|
||||
examIDs.find((e) => e.module === module)?.id || "",
|
||||
}}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setExamIDs((prev) => [
|
||||
...prev.filter((x) => x.module !== module),
|
||||
{ id: value.value!, module },
|
||||
])
|
||||
: setExamIDs((prev) =>
|
||||
: setExamIDs((prev) =>
|
||||
prev.filter((x) => x.module !== module)
|
||||
)
|
||||
}
|
||||
options={exams
|
||||
.filter((x) => !x.isDiagnostic && x.module === module)
|
||||
.map((x) => ({ value: x.id, label: x.id }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
options={exams
|
||||
.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>
|
||||
)}
|
||||
@@ -568,7 +598,7 @@ export default function AssignmentsPage({
|
||||
users
|
||||
.filter((u) => g.participants.includes(u.id))
|
||||
.every((u) => assignees.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{g.name}
|
||||
@@ -653,7 +683,7 @@ export default function AssignmentsPage({
|
||||
users
|
||||
.filter((u) => g.participants.includes(u.id))
|
||||
.every((u) => teachers.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{g.name}
|
||||
|
||||
@@ -159,6 +159,21 @@ export default function Home({ user, group, users, entity }: Props) {
|
||||
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||
);
|
||||
|
||||
const toggleAllUsersInList = () =>
|
||||
setSelectedUsers((prev) =>
|
||||
prev.length === rows.length
|
||||
? []
|
||||
: [
|
||||
...prev,
|
||||
...items.reduce((acc, i) => {
|
||||
if (!prev.find((item) => item === i.id)) {
|
||||
(acc as string[]).push(i.id);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]),
|
||||
]
|
||||
);
|
||||
|
||||
const removeParticipants = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
if (!canRemoveParticipants) return;
|
||||
@@ -428,6 +443,25 @@ export default function Home({ user, group, users, entity }: Props) {
|
||||
{capitalize(type)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleAllUsersInList();
|
||||
}}
|
||||
disabled={rows.length === 0}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||
"transition duration-300 ease-in-out",
|
||||
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
||||
(isAdding ? nonParticipantUsers : group.participants)
|
||||
.length === selectedUsers.length &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{"De/Select All"}
|
||||
</button>
|
||||
<span className="opacity-80">
|
||||
{selectedUsers.length} selected
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,269 +1,216 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import UserDisplayList from "@/components/UserDisplayList";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Stat, StudentUser, Type, User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { countGroupsByEntities } from "@/utils/groups.be";
|
||||
import {
|
||||
checkAccess,
|
||||
groupAllowedEntitiesByPermissions,
|
||||
} from "@/utils/permissions";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
import { countAllowedUsers, getUsers } from "@/utils/users.be";
|
||||
import { clsx } from "clsx";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import {useAllowedEntities} from "@/hooks/useEntityPermissions";
|
||||
import {EntityWithRoles} from "@/interfaces/entity";
|
||||
import {Stat, StudentUser, Type, User} from "@/interfaces/user";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {filterBy, mapBy, redirect, serialize} from "@/utils";
|
||||
import {requestUser} from "@/utils/api";
|
||||
import {countEntitiesAssignments} from "@/utils/assignments.be";
|
||||
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
||||
import {countGroupsByEntities} from "@/utils/groups.be";
|
||||
import {checkAccess, groupAllowedEntitiesByPermissions} from "@/utils/permissions";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import {countAllowedUsers, getUsers} from "@/utils/users.be";
|
||||
import {clsx} from "clsx";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BsBank,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
} from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import {useRouter} from "next/router";
|
||||
import {useMemo} from "react";
|
||||
import {BsBank, BsClock, BsEnvelopePaper, BsPencilSquare, BsPeople, BsPeopleFill, BsPersonFill, BsPersonFillGear} from "react-icons/bs";
|
||||
import {ToastContainer} from "react-toastify";
|
||||
import {isAdmin} from "@/utils/users";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
students: StudentUser[];
|
||||
latestStudents: User[];
|
||||
latestTeachers: User[];
|
||||
userCounts: { [key in Type]: number };
|
||||
entities: EntityWithRoles[];
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
user: User;
|
||||
students: StudentUser[];
|
||||
latestStudents: User[];
|
||||
latestTeachers: User[];
|
||||
userCounts: {[key in Type]: number};
|
||||
entities: EntityWithRoles[];
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user || !user.isVerified) return redirect("/login");
|
||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user || !user.isVerified) return redirect("/login");
|
||||
|
||||
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
|
||||
return redirect("/");
|
||||
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/");
|
||||
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
const entities = await getEntitiesWithRoles(
|
||||
isAdmin(user) ? undefined : entityIDS
|
||||
);
|
||||
const {
|
||||
["view_students"]: allowedStudentEntities,
|
||||
["view_teachers"]: allowedTeacherEntities,
|
||||
} = groupAllowedEntitiesByPermissions(user, entities, [
|
||||
"view_students",
|
||||
"view_teachers",
|
||||
]);
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const {["view_students"]: allowedStudentEntities, ["view_teachers"]: allowedTeacherEntities} = groupAllowedEntitiesByPermissions(user, entities, [
|
||||
"view_students",
|
||||
"view_teachers",
|
||||
]);
|
||||
|
||||
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
||||
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
||||
|
||||
const entitiesIDS = mapBy(entities, "id") || [];
|
||||
const entitiesIDS = mapBy(entities, "id") || [];
|
||||
|
||||
const [
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
userCounts,
|
||||
assignmentsCount,
|
||||
groupsCount,
|
||||
] = await Promise.all([
|
||||
getUsers(
|
||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||
10,
|
||||
{ averageLevel: -1 },
|
||||
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||
),
|
||||
getUsers(
|
||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||
10,
|
||||
{ registrationDate: -1 },
|
||||
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||
),
|
||||
getUsers(
|
||||
{
|
||||
type: "teacher",
|
||||
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
|
||||
},
|
||||
10,
|
||||
{ registrationDate: -1 },
|
||||
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||
),
|
||||
countAllowedUsers(user, entities),
|
||||
countEntitiesAssignments(entitiesIDS, { archived: { $ne: true } }),
|
||||
countGroupsByEntities(entitiesIDS),
|
||||
]);
|
||||
const [students, latestStudents, latestTeachers, userCounts, assignmentsCount, groupsCount] = await Promise.all([
|
||||
getUsers(
|
||||
{type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
|
||||
10,
|
||||
{averageLevel: -1},
|
||||
{_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
|
||||
),
|
||||
getUsers(
|
||||
{type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
|
||||
10,
|
||||
{registrationDate: -1},
|
||||
{_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
|
||||
),
|
||||
getUsers(
|
||||
{
|
||||
type: "teacher",
|
||||
"entities.id": {$in: mapBy(allowedTeacherEntities, "id")},
|
||||
},
|
||||
10,
|
||||
{registrationDate: -1},
|
||||
{_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
|
||||
),
|
||||
countAllowedUsers(user, entities),
|
||||
countEntitiesAssignments(entitiesIDS, {archived: {$ne: true}}),
|
||||
countGroupsByEntities(entitiesIDS),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
userCounts,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
groupsCount,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
userCounts,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
groupsCount,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
export default function Dashboard({
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
userCounts,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
stats = [],
|
||||
groupsCount,
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
userCounts,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
stats = [],
|
||||
groupsCount,
|
||||
}: Props) {
|
||||
const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]);
|
||||
|
||||
const totalCount = useMemo(
|
||||
() =>
|
||||
userCounts.corporate +
|
||||
userCounts.mastercorporate +
|
||||
userCounts.student +
|
||||
userCounts.teacher,
|
||||
[userCounts]
|
||||
);
|
||||
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]);
|
||||
|
||||
const totalLicenses = useMemo(
|
||||
() =>
|
||||
entities.reduce(
|
||||
(acc, curr) => acc + parseInt(curr.licenses.toString()),
|
||||
0
|
||||
),
|
||||
[entities]
|
||||
);
|
||||
const router = useRouter();
|
||||
|
||||
const router = useRouter();
|
||||
const allowedEntityStatistics = useAllowedEntities(user, entities, "view_entity_statistics");
|
||||
const allowedStudentPerformance = useAllowedEntities(user, entities, "view_student_performance");
|
||||
|
||||
const allowedEntityStatistics = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_entity_statistics"
|
||||
);
|
||||
const allowedStudentPerformance = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_student_performance"
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<>
|
||||
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={userCounts.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate Accounts"
|
||||
value={userCounts.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=mastercorporate")}
|
||||
label="Master Corporates"
|
||||
value={userCounts.mastercorporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPeople} onClick={() => router.push("/classrooms")} label="Classrooms" value={groupsCount} color="purple" />
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
className={clsx(allowedEntityStatistics.length === 0 && "col-span-2")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<>
|
||||
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={userCounts.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate Accounts"
|
||||
value={userCounts.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
onClick={() => router.push("/classrooms")}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
className={clsx(
|
||||
allowedEntityStatistics.length === 0 && "col-span-2"
|
||||
)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={
|
||||
user.subscriptionExpirationDate
|
||||
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
|
||||
: "Unlimited"
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<UserDisplayList users={latestStudents} title="Latest Students" />
|
||||
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
|
||||
<UserDisplayList users={students} title="Highest level students" />
|
||||
<UserDisplayList
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length
|
||||
)}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<UserDisplayList users={latestStudents} title="Latest Students" />
|
||||
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
|
||||
<UserDisplayList users={students} title="Highest level students" />
|
||||
<UserDisplayList
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
|
||||
)}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ const EXAM_MANAGEMENT: PermissionLayout[] = [
|
||||
{label: "Generate Level", key: "generate_level"},
|
||||
{label: "Delete Level", key: "delete_level"},
|
||||
{label: "Set as Private/Public", key: "update_exam_privacy"},
|
||||
{label: "View Confidential Exams", key: "view_confidential_exams"},
|
||||
{label: "Create Confidential Exams", key: "create_confidential_exams"},
|
||||
{label: "Create Public Exams", key: "create_public_exams"},
|
||||
{label: "View Statistics", key: "view_statistics"},
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,250 +1,325 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {ToastContainer} from "react-toastify";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import {Radio, RadioGroup} from "@headlessui/react";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import Input from "@/components/Low/Input";
|
||||
import {findAllowedEntities} from "@/utils/permissions";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {
|
||||
findAllowedEntities,
|
||||
findAllowedEntitiesSomePermissions,
|
||||
groupAllowedEntitiesByPermissions,
|
||||
} from "@/utils/permissions";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import ExamEditorStore from "@/stores/examEditor/types";
|
||||
import ExamEditor from "@/components/ExamEditor";
|
||||
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 {useEffect, useState} from "react";
|
||||
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
||||
import {isAdmin} from "@/utils/users";
|
||||
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 { useEffect, useState } from "react";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import axios from "axios";
|
||||
import {EntityWithRoles} from "@/interfaces/entity";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
type Permission = {[key in Module]: boolean};
|
||||
type Permission = { [key in Module]: boolean };
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
export const getServerSideProps = withIronSessionSsr(
|
||||
async ({ req, res, query }) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
const entityIDs = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
|
||||
const entityIDs = mapBy(user.entities, "id");
|
||||
|
||||
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,
|
||||
};
|
||||
const entities = await getEntitiesWithRoles(
|
||||
isAdmin(user) ? undefined : entityIDs
|
||||
);
|
||||
|
||||
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy");
|
||||
console.log(entitiesAllowEditPrivacy);
|
||||
const generatePermissions = groupAllowedEntitiesByPermissions(
|
||||
user,
|
||||
entities,
|
||||
[
|
||||
"generate_reading",
|
||||
"generate_listening",
|
||||
"generate_writing",
|
||||
"generate_speaking",
|
||||
"generate_level",
|
||||
]
|
||||
);
|
||||
|
||||
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/");
|
||||
const permissions: Permission = {
|
||||
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 {id, module: examModule} = query as {id?: string; module?: Module};
|
||||
if (!id || !examModule) return {props: serialize({user, permissions})};
|
||||
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 (!permissions[module]) return redirect("/generation")
|
||||
if (Object.keys(permissions).every((p) => !permissions[p as Module]))
|
||||
return redirect("/");
|
||||
|
||||
const exam = await getExam(examModule, id);
|
||||
if (!exam) return redirect("/generation");
|
||||
const { id, module: examModule } = query as {
|
||||
id?: string;
|
||||
module?: Module;
|
||||
};
|
||||
if (!id || !examModule) return { props: serialize({ user, permissions }) };
|
||||
|
||||
return {
|
||||
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
//if (!permissions[module]) return redirect("/generation")
|
||||
|
||||
const exam = await getExam(examModule, id);
|
||||
if (!exam) return redirect("/generation");
|
||||
|
||||
return {
|
||||
props: serialize({
|
||||
id,
|
||||
user,
|
||||
exam,
|
||||
examModule,
|
||||
permissions,
|
||||
entitiesAllowEditPrivacy,
|
||||
entitiesAllowConfExams,
|
||||
entitiesAllowPublicExams,
|
||||
}),
|
||||
};
|
||||
},
|
||||
sessionOptions
|
||||
);
|
||||
|
||||
export default function Generation({
|
||||
id,
|
||||
user,
|
||||
exam,
|
||||
examModule,
|
||||
permissions,
|
||||
entitiesAllowEditPrivacy,
|
||||
id,
|
||||
user,
|
||||
exam,
|
||||
examModule,
|
||||
permissions,
|
||||
entitiesAllowEditPrivacy,
|
||||
entitiesAllowConfExams,
|
||||
entitiesAllowPublicExams,
|
||||
}: {
|
||||
id: string;
|
||||
user: User;
|
||||
exam?: Exam;
|
||||
examModule?: Module;
|
||||
permissions: Permission;
|
||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||
id: string;
|
||||
user: User;
|
||||
exam?: Exam;
|
||||
examModule?: Module;
|
||||
permissions: Permission;
|
||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||
entitiesAllowPublicExams: EntityWithRoles[];
|
||||
entitiesAllowConfExams: EntityWithRoles[];
|
||||
}) {
|
||||
const {title, currentModule, modules, dispatch} = useExamEditorStore();
|
||||
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined);
|
||||
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
||||
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
||||
dispatch({type: "UPDATE_ROOT", payload: {updates}});
|
||||
};
|
||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
||||
dispatch({ type: "UPDATE_ROOT", payload: { updates } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id && exam && examModule) {
|
||||
if (examModule === "level" && exam.module === "level") {
|
||||
setExamLevelParts(exam.parts.length);
|
||||
}
|
||||
updateRoot({currentModule: examModule});
|
||||
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, exam, module]);
|
||||
useEffect(() => {
|
||||
if (id && exam && examModule) {
|
||||
if (examModule === "level" && exam.module === "level") {
|
||||
setExamLevelParts(exam.parts.length);
|
||||
}
|
||||
updateRoot({ currentModule: examModule });
|
||||
dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, exam, module]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAvatars = async () => {
|
||||
const response = await axios.get("/api/exam/avatars");
|
||||
updateRoot({speakingAvatars: response.data});
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchAvatars = async () => {
|
||||
const response = await axios.get("/api/exam/avatars");
|
||||
updateRoot({ speakingAvatars: response.data });
|
||||
};
|
||||
|
||||
fetchAvatars();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
fetchAvatars();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// media cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const state = modules;
|
||||
// media cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const state = modules;
|
||||
|
||||
if (state.writing.academic_url) {
|
||||
URL.revokeObjectURL(state.writing.academic_url);
|
||||
}
|
||||
if (state.writing.academic_url) {
|
||||
URL.revokeObjectURL(state.writing.academic_url);
|
||||
}
|
||||
|
||||
state.listening.sections.forEach((section) => {
|
||||
const listeningPart = section.state as ListeningPart;
|
||||
if (listeningPart.audio?.source) {
|
||||
URL.revokeObjectURL(listeningPart.audio.source);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId: section.sectionId,
|
||||
module: "listening",
|
||||
field: "state",
|
||||
value: {...listeningPart, audio: undefined},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
state.listening.sections.forEach((section) => {
|
||||
const listeningPart = section.state as ListeningPart;
|
||||
if (listeningPart.audio?.source) {
|
||||
URL.revokeObjectURL(listeningPart.audio.source);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId: section.sectionId,
|
||||
module: "listening",
|
||||
field: "state",
|
||||
value: { ...listeningPart, audio: undefined },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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) => {
|
||||
const sectionState = section.state as Exercise;
|
||||
if (sectionState.type === "speaking") {
|
||||
const speakingExercise = sectionState as SpeakingExercise;
|
||||
URL.revokeObjectURL(speakingExercise.video_url);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId: section.sectionId,
|
||||
module: "listening",
|
||||
field: "state",
|
||||
value: {...speakingExercise, video_url: undefined},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sectionState.type === "interactiveSpeaking") {
|
||||
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
||||
interactiveSpeaking.prompts.forEach((prompt) => {
|
||||
URL.revokeObjectURL(prompt.video_url);
|
||||
});
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId: section.sectionId,
|
||||
module: "listening",
|
||||
field: "state",
|
||||
value: {
|
||||
...interactiveSpeaking,
|
||||
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
dispatch({type: "FULL_RESET"});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
state.speaking.sections.forEach((section) => {
|
||||
const sectionState = section.state as Exercise;
|
||||
if (sectionState.type === "speaking") {
|
||||
const speakingExercise = sectionState as SpeakingExercise;
|
||||
URL.revokeObjectURL(speakingExercise.video_url);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId: section.sectionId,
|
||||
module: "listening",
|
||||
field: "state",
|
||||
value: { ...speakingExercise, video_url: undefined },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sectionState.type === "interactiveSpeaking") {
|
||||
const interactiveSpeaking =
|
||||
sectionState as InteractiveSpeakingExercise;
|
||||
interactiveSpeaking.prompts.forEach((prompt) => {
|
||||
URL.revokeObjectURL(prompt.video_url);
|
||||
});
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId: section.sectionId,
|
||||
module: "listening",
|
||||
field: "state",
|
||||
value: {
|
||||
...interactiveSpeaking,
|
||||
prompts: interactiveSpeaking.prompts.map((p) => ({
|
||||
...p,
|
||||
video_url: undefined,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
dispatch({ type: "FULL_RESET" });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exam Generation | 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 />
|
||||
{user && (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold">Exam Editor</h1>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Insert a title here"
|
||||
name="title"
|
||||
label="Title"
|
||||
onChange={(title) => updateRoot({title})}
|
||||
roundness="xl"
|
||||
value={title}
|
||||
defaultValue={title}
|
||||
required
|
||||
/>
|
||||
<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) => (
|
||||
<Radio value={x} key={x}>
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-64 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",
|
||||
x === "reading" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||
x === "listening" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||
x === "writing" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||
x === "speaking" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||
x === "level" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-level/70 border-ielts-level text-white"),
|
||||
)}>
|
||||
{capitalize(x)}
|
||||
</span>
|
||||
)}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exam Generation | 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 />
|
||||
{user && (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold">Exam Editor</h1>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Insert a title here"
|
||||
name="title"
|
||||
label="Title"
|
||||
onChange={(title) => updateRoot({ title })}
|
||||
roundness="xl"
|
||||
value={title}
|
||||
defaultValue={title}
|
||||
required
|
||||
/>
|
||||
<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].reduce((acc, x) => {
|
||||
if (permissions[x])
|
||||
acc.push(
|
||||
<Radio value={x} key={x}>
|
||||
{({ checked }) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-64 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",
|
||||
x === "reading" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||
x === "listening" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||
x === "writing" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||
x === "speaking" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||
x === "level" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "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}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,268 +1,201 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {ToastContainer} from "react-toastify";
|
||||
import CodeGenerator from "./(admin)/CodeGenerator";
|
||||
import ExamLoader from "./(admin)/ExamLoader";
|
||||
import Lists from "./(admin)/Lists";
|
||||
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { useState } from "react";
|
||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||
import { useState} from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import {
|
||||
BsCode,
|
||||
BsCodeSquare,
|
||||
BsGearFill,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
} from "react-icons/bs";
|
||||
import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs";
|
||||
import UserCreator from "./(admin)/UserCreator";
|
||||
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
|
||||
import { CEFR_STEPS } from "@/resources/grading";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { getUserPermissions } from "@/utils/permissions.be";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import { getUsers } from "@/utils/users.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { mapBy, serialize, redirect } from "@/utils";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import {
|
||||
getGradingSystemByEntities,
|
||||
getGradingSystemByEntity,
|
||||
} from "@/utils/grading.be";
|
||||
import { Grading } from "@/interfaces";
|
||||
import { useRouter } from "next/router";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import {CEFR_STEPS} from "@/resources/grading";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {getUserPermissions} from "@/utils/permissions.be";
|
||||
import {PermissionType} from "@/interfaces/permissions";
|
||||
import {getUsers} from "@/utils/users.be";
|
||||
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
||||
import {mapBy, serialize, redirect, filterBy} from "@/utils";
|
||||
import {EntityWithRoles} from "@/interfaces/entity";
|
||||
import {requestUser} from "@/utils/api";
|
||||
import {isAdmin} from "@/utils/users";
|
||||
import {getGradingSystemByEntities, getGradingSystemByEntity} from "@/utils/grading.be";
|
||||
import {Grading} from "@/interfaces";
|
||||
import {useRouter} from "next/router";
|
||||
import {useAllowedEntities} from "@/hooks/useEntityPermissions";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (
|
||||
shouldRedirectHome(user) ||
|
||||
!checkAccess(user, [
|
||||
"admin",
|
||||
"developer",
|
||||
"corporate",
|
||||
"teacher",
|
||||
"mastercorporate",
|
||||
])
|
||||
)
|
||||
return redirect("/");
|
||||
const [permissions, entities, allUsers] = await Promise.all([
|
||||
getUserPermissions(user.id),
|
||||
isAdmin(user)
|
||||
? await getEntitiesWithRoles()
|
||||
: await getEntitiesWithRoles(mapBy(user.entities, "id")),
|
||||
getUsers(),
|
||||
]);
|
||||
const gradingSystems = await getGradingSystemByEntities(
|
||||
mapBy(entities, "id")
|
||||
);
|
||||
const entitiesGrading = entities.map(
|
||||
(e) =>
|
||||
gradingSystems.find((g) => g.entity === e.id) || {
|
||||
entity: e.id,
|
||||
steps: CEFR_STEPS,
|
||||
}
|
||||
);
|
||||
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) return redirect("/");
|
||||
const [permissions, entities, allUsers] = await Promise.all([
|
||||
getUserPermissions(user.id),
|
||||
isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, "id")),
|
||||
getUsers(),
|
||||
]);
|
||||
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, "id"));
|
||||
const entitiesGrading = entities.map(
|
||||
(e) =>
|
||||
gradingSystems.find((g) => g.entity === e.id) || {
|
||||
entity: e.id,
|
||||
steps: CEFR_STEPS,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
permissions,
|
||||
entities,
|
||||
allUsers,
|
||||
entitiesGrading,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
permissions,
|
||||
entities,
|
||||
allUsers,
|
||||
entitiesGrading,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[];
|
||||
allUsers: User[];
|
||||
entitiesGrading: Grading[];
|
||||
user: User;
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[];
|
||||
allUsers: User[];
|
||||
entitiesGrading: Grading[];
|
||||
}
|
||||
|
||||
export default function Admin({
|
||||
user,
|
||||
entities,
|
||||
permissions,
|
||||
allUsers,
|
||||
entitiesGrading,
|
||||
}: Props) {
|
||||
const [modalOpen, setModalOpen] = useState<string>();
|
||||
const router = useRouter();
|
||||
export default function Admin({user, entities, permissions, allUsers, entitiesGrading}: Props) {
|
||||
const [modalOpen, setModalOpen] = useState<string>();
|
||||
const router = useRouter();
|
||||
|
||||
const entitiesAllowCreateUser = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"create_user"
|
||||
);
|
||||
const entitiesAllowCreateUsers = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"create_user_batch"
|
||||
);
|
||||
const entitiesAllowCreateCode = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"create_code"
|
||||
);
|
||||
const entitiesAllowCreateCodes = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"create_code_batch"
|
||||
);
|
||||
const entitiesAllowEditGrading = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"edit_grading_system"
|
||||
);
|
||||
const entitiesAllowCreateUser = useAllowedEntities(user, entities, "create_user");
|
||||
const entitiesAllowCreateUsers = useAllowedEntities(user, entities, "create_user_batch");
|
||||
const entitiesAllowCreateCode = useAllowedEntities(user, entities, "create_code");
|
||||
const entitiesAllowCreateCodes = useAllowedEntities(user, entities, "create_code_batch");
|
||||
const entitiesAllowEditGrading = useAllowedEntities(user, entities, "edit_grading_system");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Settings Panel | 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 />
|
||||
<>
|
||||
<Modal
|
||||
isOpen={modalOpen === "batchCreateUser"}
|
||||
onClose={() => setModalOpen(undefined)}
|
||||
maxWidth="max-w-[85%]"
|
||||
>
|
||||
<BatchCreateUser
|
||||
user={user}
|
||||
entities={entitiesAllowCreateUser}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={modalOpen === "batchCreateCode"}
|
||||
onClose={() => setModalOpen(undefined)}
|
||||
>
|
||||
<BatchCodeGenerator
|
||||
entities={entitiesAllowCreateCodes}
|
||||
user={user}
|
||||
users={allUsers}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={modalOpen === "createCode"}
|
||||
onClose={() => setModalOpen(undefined)}
|
||||
>
|
||||
<CodeGenerator
|
||||
entities={entitiesAllowCreateCode}
|
||||
user={user}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={modalOpen === "createUser"}
|
||||
onClose={() => setModalOpen(undefined)}
|
||||
>
|
||||
<UserCreator
|
||||
user={user}
|
||||
entities={entitiesAllowCreateUsers}
|
||||
users={allUsers}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={modalOpen === "gradingSystem"}
|
||||
onClose={() => setModalOpen(undefined)}
|
||||
>
|
||||
<CorporateGradingSystem
|
||||
user={user}
|
||||
entitiesGrading={entitiesGrading}
|
||||
entities={entitiesAllowEditGrading}
|
||||
mutate={() => router.replace(router.asPath)}
|
||||
/>
|
||||
</Modal>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Settings Panel | 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 />
|
||||
<>
|
||||
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]">
|
||||
<BatchCreateUser
|
||||
user={user}
|
||||
entities={entitiesAllowCreateUsers.filter(
|
||||
(e) =>
|
||||
e.licenses > 0 &&
|
||||
e.licenses > allUsers.filter((u) => !isAdmin(u) && (u.entities || []).some((ent) => ent.id === e.id)).length,
|
||||
)}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
|
||||
<BatchCodeGenerator
|
||||
entities={entitiesAllowCreateCodes}
|
||||
user={user}
|
||||
users={allUsers}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
|
||||
<CodeGenerator
|
||||
entities={entitiesAllowCreateCode}
|
||||
user={user}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
|
||||
<UserCreator
|
||||
user={user}
|
||||
entities={entitiesAllowCreateUser.filter(
|
||||
(e) =>
|
||||
e.licenses > 0 &&
|
||||
e.licenses > allUsers.filter((u) => !isAdmin(u) && (u.entities || []).some((ent) => ent.id === e.id)).length,
|
||||
)}
|
||||
users={allUsers}
|
||||
permissions={permissions}
|
||||
onFinish={() => setModalOpen(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
|
||||
<CorporateGradingSystem
|
||||
user={user}
|
||||
entitiesGrading={entitiesGrading}
|
||||
entities={entitiesAllowEditGrading}
|
||||
mutate={() => router.replace(router.asPath)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
|
||||
<ExamLoader />
|
||||
{checkAccess(
|
||||
user,
|
||||
getTypesOfUser(["teacher"]),
|
||||
permissions,
|
||||
"viewCodes"
|
||||
) && (
|
||||
<div className="w-full grid grid-cols-2 gap-4">
|
||||
<IconCard
|
||||
Icon={BsCode}
|
||||
label="Generate Single Code"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("createCode")}
|
||||
disabled={entitiesAllowCreateCode.length === 0}
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsCodeSquare}
|
||||
label="Generate Codes in Batch"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("batchCreateCode")}
|
||||
disabled={entitiesAllowCreateCodes.length === 0}
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonFill}
|
||||
label="Create Single User"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("createUser")}
|
||||
disabled={entitiesAllowCreateUser.length === 0}
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
label="Create Users in Batch"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("batchCreateUser")}
|
||||
disabled={entitiesAllowCreateUsers.length === 0}
|
||||
/>
|
||||
{checkAccess(user, [
|
||||
"admin",
|
||||
"corporate",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<IconCard
|
||||
Icon={BsGearFill}
|
||||
label="Grading System"
|
||||
color="purple"
|
||||
className="w-full h-full col-span-2"
|
||||
onClick={() => setModalOpen("gradingSystem")}
|
||||
disabled={entitiesAllowEditGrading.length === 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="w-full">
|
||||
<Lists user={user} entities={entities} permissions={permissions} />
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
|
||||
<ExamLoader />
|
||||
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
|
||||
<div className="w-full grid grid-cols-2 gap-4">
|
||||
<IconCard
|
||||
Icon={BsCode}
|
||||
label="Generate Single Code"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("createCode")}
|
||||
disabled={entitiesAllowCreateCode.length === 0}
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsCodeSquare}
|
||||
label="Generate Codes in Batch"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("batchCreateCode")}
|
||||
disabled={entitiesAllowCreateCodes.length === 0}
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonFill}
|
||||
label="Create Single User"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("createUser")}
|
||||
disabled={entitiesAllowCreateUser.length === 0}
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
label="Create Users in Batch"
|
||||
color="purple"
|
||||
className="w-full h-full"
|
||||
onClick={() => setModalOpen("batchCreateUser")}
|
||||
disabled={entitiesAllowCreateUsers.length === 0}
|
||||
/>
|
||||
{checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && (
|
||||
<IconCard
|
||||
Icon={BsGearFill}
|
||||
label="Grading System"
|
||||
color="purple"
|
||||
className="w-full h-full col-span-2"
|
||||
onClick={() => setModalOpen("gradingSystem")}
|
||||
disabled={entitiesAllowEditGrading.length === 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="w-full">
|
||||
<Lists user={user} entities={entities} permissions={permissions} />
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,32 @@ const Training: React.FC<{
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
|
||||
<RecordFilter
|
||||
entities={entities}
|
||||
user={user}
|
||||
isAdmin={isAdmin}
|
||||
filterState={{ filter: filter, setFilter: setFilter }}
|
||||
assignments={false}
|
||||
>
|
||||
{user.type === "student" && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="font-semibold text-2xl">
|
||||
Generate New Training Material
|
||||
</div>
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
onClick={handleNewTrainingContent}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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">
|
||||
@@ -215,38 +241,10 @@ const Training: React.FC<{
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RecordFilter
|
||||
entities={entities}
|
||||
user={user}
|
||||
isAdmin={isAdmin}
|
||||
filterState={{ filter: filter, setFilter: setFilter }}
|
||||
assignments={false}
|
||||
>
|
||||
{user.type === "student" && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="font-semibold text-2xl">
|
||||
Generate New Training Material
|
||||
</div>
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
onClick={handleNewTrainingContent}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RecordFilter>
|
||||
{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 &&
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,10 @@ export type RolePermission =
|
||||
| "view_workflows"
|
||||
| "configure_workflows"
|
||||
| "edit_workflow"
|
||||
| "delete_workflow";
|
||||
| "delete_workflow"
|
||||
| "view_confidential_exams"
|
||||
| "create_confidential_exams"
|
||||
| "create_public_exams";
|
||||
|
||||
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
||||
"view_students",
|
||||
@@ -156,4 +159,6 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
|
||||
"view_workflows",
|
||||
"edit_workflow",
|
||||
"delete_workflow",
|
||||
"create_confidential_exams",
|
||||
"create_public_exams",
|
||||
];
|
||||
|
||||
@@ -14,7 +14,7 @@ export type RootActions =
|
||||
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } |
|
||||
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
|
||||
{ type: 'FINALIZE_MODULE_SOLUTIONS' } |
|
||||
{ type: 'UPDATE_EXAMS'}
|
||||
{ type: 'UPDATE_EXAMS' }
|
||||
|
||||
|
||||
export type Action = RootActions | SessionActions;
|
||||
|
||||
@@ -158,7 +158,7 @@ const defaultModuleSettings = (module: Module, minTimer: number, reset: boolean
|
||||
examLabel: defaultExamLabel(module),
|
||||
minTimer,
|
||||
difficulty: [sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!],
|
||||
isPrivate: true,
|
||||
access: "private",
|
||||
sectionLabels: sectionLabels(module),
|
||||
expandedSections: [(reset && (module === "writing" || module === "speaking")) ? 0 : 1],
|
||||
focusedSection: 1,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReduce
|
||||
import { Module } from "@/interfaces";
|
||||
import { updateExamWithUserSolutions } from "@/stores/exam/utils";
|
||||
import { defaultExamUserSolutions } from "@/utils/exams";
|
||||
import { access } from "fs";
|
||||
|
||||
type RootActions = { type: 'FULL_RESET' } |
|
||||
{ type: 'INIT_EXAM_EDIT', payload: { exam: Exam; examModule: Module; id: string } } |
|
||||
@@ -121,7 +122,7 @@ export const rootReducer = (
|
||||
...defaultModuleSettings(examModule, exam.minTimer),
|
||||
examLabel: exam.label,
|
||||
difficulty: exam.difficulty,
|
||||
isPrivate: exam.private,
|
||||
access: exam.access,
|
||||
sections: examState,
|
||||
importModule: false,
|
||||
sectionLabels:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import { AccessType, Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import { Module } from "@/interfaces";
|
||||
import Option from "@/interfaces/option";
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface LevelSectionSettings extends SectionSettings {
|
||||
|
||||
export type Context = "passage" | "video" | "audio" | "listeningScript" | "speakingScript" | "writing";
|
||||
export type Generating = Context | "exercises" | string | undefined;
|
||||
export type LevelGenResults = {generating: string, result: Record<string, any>[], module: Module};
|
||||
export type LevelGenResults = { generating: string, result: Record<string, any>[], module: Module };
|
||||
export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
|
||||
export type ExamPart = ListeningPart | ReadingPart | LevelPart;
|
||||
|
||||
@@ -97,10 +97,10 @@ export interface SectionState {
|
||||
state: Section;
|
||||
expandedSubSections: number[];
|
||||
generating: Generating;
|
||||
genResult: {generating: string, result: Record<string, any>[], module: Module} | undefined;
|
||||
genResult: { generating: string, result: Record<string, any>[], module: Module } | undefined;
|
||||
levelGenerating: Generating[];
|
||||
levelGenResults: LevelGenResults[];
|
||||
focusedExercise?: {questionId: number; id: string} | undefined;
|
||||
focusedExercise?: { questionId: number; id: string } | undefined;
|
||||
writingSection?: number;
|
||||
speakingSection?: number;
|
||||
readingSection?: number;
|
||||
@@ -126,8 +126,8 @@ export interface ModuleState {
|
||||
sections: SectionState[];
|
||||
minTimer: number;
|
||||
difficulty: Difficulty[];
|
||||
isPrivate: boolean;
|
||||
sectionLabels: {id: number; label: string;}[];
|
||||
access: AccessType;
|
||||
sectionLabels: { id: number; label: string; }[];
|
||||
expandedSections: number[];
|
||||
focusedSection: number;
|
||||
importModule: boolean;
|
||||
|
||||
@@ -4,11 +4,26 @@ import { ObjectId } from "mongodb";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export const getApprovalWorkflows = async (collection: string, ids?: string[]) => {
|
||||
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[], assignee?: string) => {
|
||||
const filters: any = {};
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
filters.id = { $in: ids };
|
||||
}
|
||||
|
||||
if (entityIds && entityIds.length > 0) {
|
||||
filters.entityId = { $in: entityIds };
|
||||
}
|
||||
|
||||
if (assignee) {
|
||||
filters["steps.assignees"] = assignee;
|
||||
}
|
||||
|
||||
return await db
|
||||
.collection<ApprovalWorkflow>(collection)
|
||||
.find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {})
|
||||
.toArray();
|
||||
.collection<ApprovalWorkflow>(collection)
|
||||
.find(filters)
|
||||
.sort({ startDate: -1 })
|
||||
.toArray();
|
||||
};
|
||||
|
||||
export const getApprovalWorkflow = async (collection: string, id: string) => {
|
||||
@@ -19,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();
|
||||
};
|
||||
|
||||
@@ -39,7 +55,7 @@ export const getApprovalWorkflowsByExamId = async (examId: string) => {
|
||||
.collection<ApprovalWorkflow>("active-workflows")
|
||||
.find({
|
||||
examId,
|
||||
status: { $in: ["pending"] }
|
||||
status: { $in: ["pending"] },
|
||||
})
|
||||
.toArray();
|
||||
};
|
||||
|
||||
@@ -1,84 +1,168 @@
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { diff, Diff } from "deep-diff";
|
||||
|
||||
const EXCLUDED_FIELDS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private"]);
|
||||
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",
|
||||
parts: "Parts",
|
||||
exercises: "Exercises",
|
||||
userSolutions: "User Solutions",
|
||||
words: "Words",
|
||||
options: "Options",
|
||||
prompt: "Prompt",
|
||||
text: "Text",
|
||||
audio: "Audio",
|
||||
script: "Script",
|
||||
difficulty: "Difficulty",
|
||||
shuffle: "Shuffle",
|
||||
solutions: "Solutions",
|
||||
variant: "Variant",
|
||||
prefix: "Prefix",
|
||||
suffix: "Suffix",
|
||||
topic: "Topic",
|
||||
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_FIELDS.has(segment))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert path array to something human-readable
|
||||
const pathString = change.path.join(" \u2192 "); // e.g. "parts → 0 → exercises → 1 → prompt"
|
||||
|
||||
switch (change.kind) {
|
||||
case "N":
|
||||
// A new property/element was added
|
||||
return `\u{2022} Added \`${pathString}\` with value: ${formatValue(change.rhs)}`;
|
||||
|
||||
case "D":
|
||||
// A property/element was deleted
|
||||
return `\u{2022} Removed \`${pathString}\` which had value: ${formatValue(change.lhs)}`;
|
||||
|
||||
case "E":
|
||||
// A property/element was edited
|
||||
return `\u{2022} Changed \`${pathString}\` from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}`;
|
||||
|
||||
case "A":
|
||||
// An array change; change.item describes what happened at array index change.index
|
||||
return formatArrayChange(change);
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
function isObject(val: any): val is Record<string, any> {
|
||||
return val !== null && typeof val === "object" && !Array.isArray(val);
|
||||
}
|
||||
|
||||
function formatArrayChange(change: Diff<any, any>): string | undefined {
|
||||
if (!change.path) return;
|
||||
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathString = change.path.join(" \u2192 ");
|
||||
|
||||
const arrayChange = (change as any).item;
|
||||
const idx = (change as any).index;
|
||||
|
||||
if (!arrayChange) return;
|
||||
|
||||
switch (arrayChange.kind) {
|
||||
case "N":
|
||||
return `\u{2022} Added an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.rhs)}`;
|
||||
case "D":
|
||||
return `\u{2022} Removed an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.lhs)}`;
|
||||
case "E":
|
||||
return `\u{2022} Edited an item at index [${idx}] in \`${pathString}\` from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}`;
|
||||
case "A":
|
||||
// Nested array changes could happen theoretically; handle or ignore similarly
|
||||
return `\u{2022} Complex array change at index [${idx}] in \`${pathString}\`: ${JSON.stringify(arrayChange)}`;
|
||||
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 {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
if (value === null) return "null";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function pathToHumanReadable(pathSegments: Array<string | number>): string {
|
||||
const mapped = pathSegments.map((seg) => {
|
||||
if (typeof seg === "number") {
|
||||
return `#${seg + 1}`;
|
||||
}
|
||||
return PATH_LABELS[seg] ?? seg;
|
||||
});
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export const getExams = async (
|
||||
.collection(module)
|
||||
.find<Exam>({
|
||||
isDiagnostic: false,
|
||||
access: "public",
|
||||
})
|
||||
.toArray();
|
||||
|
||||
@@ -72,7 +73,7 @@ export const getExams = async (
|
||||
...doc,
|
||||
module,
|
||||
})) as Exam[],
|
||||
).filter((x) => !x.private);
|
||||
)
|
||||
|
||||
let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId);
|
||||
exams = filterByVariant(exams, variant);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import { Module } from "@/interfaces";
|
||||
import {
|
||||
Exam,
|
||||
ReadingExam,
|
||||
@@ -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 => {
|
||||
@@ -88,26 +88,26 @@ export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSoluti
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
|
||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||
return { ...defaultSettings, score: { correct: 0, total, missing: total } };
|
||||
case "matchSentences":
|
||||
total = exercise.sentences.length;
|
||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||
return { ...defaultSettings, score: { correct: 0, total, missing: total } };
|
||||
case "multipleChoice":
|
||||
total = exercise.questions.length;
|
||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||
return { ...defaultSettings, score: { correct: 0, total, missing: total } };
|
||||
case "writeBlanks":
|
||||
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
|
||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||
return { ...defaultSettings, score: { correct: 0, total, missing: total } };
|
||||
case "trueFalse":
|
||||
total = exercise.questions.length;
|
||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||
return { ...defaultSettings, score: { correct: 0, total, missing: total } };
|
||||
case "writing":
|
||||
total = 1;
|
||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||
return { ...defaultSettings, score: { correct: 0, total, missing: total } };
|
||||
case "speaking":
|
||||
total = 1;
|
||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||
return { ...defaultSettings, score: { correct: 0, total, missing: total } };
|
||||
default:
|
||||
return {...defaultSettings, score: {correct: 0, total: 0, missing: 0}};
|
||||
return { ...defaultSettings, score: { correct: 0, total: 0, missing: 0 } };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const sortByModuleName = (a: string, b: string) => {
|
||||
};
|
||||
|
||||
export const countExercises = (exercises: Exercise[]) => {
|
||||
const lengthMap = exercises.map((e) => {
|
||||
const lengthMap = (exercises ?? []).map((e) => {
|
||||
if (e.type === "multipleChoice") return e.questions.length;
|
||||
if (e.type === "interactiveSpeaking") return e.prompts.length;
|
||||
if (e.type === "fillBlanks") return e.solutions.length;
|
||||
@@ -40,37 +40,37 @@ export const countCurrentExercises = (
|
||||
exercises: Exercise[],
|
||||
exerciseIndex: number,
|
||||
questionIndex?: number
|
||||
) => {
|
||||
) => {
|
||||
return exercises.reduce((acc, exercise, index) => {
|
||||
if (index > exerciseIndex) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
if (exercise.type === "multipleChoice") {
|
||||
if (index === exerciseIndex && questionIndex !== undefined) {
|
||||
count = questionIndex + 1;
|
||||
} else {
|
||||
count = exercise.questions!.length;
|
||||
if (index > exerciseIndex) {
|
||||
return acc;
|
||||
}
|
||||
} else if (exercise.type === "interactiveSpeaking") {
|
||||
count = exercise.prompts.length;
|
||||
} else if (exercise.type === "fillBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "writeBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "matchSentences") {
|
||||
count = exercise.sentences.length;
|
||||
} else if (exercise.type === "trueFalse") {
|
||||
count = exercise.questions.length;
|
||||
} else {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return acc + count;
|
||||
let count = 0;
|
||||
|
||||
if (exercise.type === "multipleChoice") {
|
||||
if (index === exerciseIndex && questionIndex !== undefined) {
|
||||
count = questionIndex + 1;
|
||||
} else {
|
||||
count = exercise.questions!.length;
|
||||
}
|
||||
} else if (exercise.type === "interactiveSpeaking") {
|
||||
count = exercise.prompts.length;
|
||||
} else if (exercise.type === "fillBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "writeBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "matchSentences") {
|
||||
count = exercise.sentences.length;
|
||||
} else if (exercise.type === "trueFalse") {
|
||||
count = exercise.questions.length;
|
||||
} else {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return acc + count;
|
||||
}, 0);
|
||||
};
|
||||
};
|
||||
|
||||
export const countFullExams = (stats: Stat[]) => {
|
||||
const sessionExams = groupBySession(stats);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EntityWithRoles, Role } from "@/interfaces/entity";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import { User, Type, userTypes } from "@/interfaces/user";
|
||||
import { RolePermission } from "@/resources/entityPermissions";
|
||||
import axios from "axios";
|
||||
import { findBy, mapBy } from ".";
|
||||
import { isAdmin } from "./users";
|
||||
|
||||
@@ -55,11 +54,11 @@ export function groupAllowedEntitiesByPermissions(
|
||||
const userEntity = userEntityMap.get(entity.id);
|
||||
const role = userEntity
|
||||
? roleCache.get(userEntity.role) ??
|
||||
(() => {
|
||||
const foundRole = entity.roles.find(r => r.id === userEntity.role) || null;
|
||||
roleCache.set(userEntity.role, foundRole);
|
||||
return foundRole;
|
||||
})()
|
||||
(() => {
|
||||
const foundRole = entity.roles.find(r => r.id === userEntity.role) || null;
|
||||
roleCache.set(userEntity.role, foundRole);
|
||||
return foundRole;
|
||||
})()
|
||||
: null;
|
||||
|
||||
permissions.forEach(permission => {
|
||||
@@ -76,7 +75,7 @@ export function groupAllowedEntitiesByPermissions(
|
||||
export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) {
|
||||
if (["admin", "developer"].includes(user?.type)) return entities
|
||||
|
||||
const allowedEntities = entities.filter((e) => doesEntityAllow(user, e, permission))
|
||||
const allowedEntities = (entities ?? []).filter((e) => doesEntityAllow(user, e, permission))
|
||||
return allowedEntities
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Module, Step} from "@/interfaces";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import { Module, Step } from "@/interfaces";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
|
||||
type Type = "academic" | "general";
|
||||
|
||||
export const writingReverseMarking: {[key: number]: number} = {
|
||||
export const writingReverseMarking: { [key: number]: number } = {
|
||||
9: 90,
|
||||
8.5: 85,
|
||||
8: 80,
|
||||
@@ -25,7 +25,7 @@ export const writingReverseMarking: {[key: number]: number} = {
|
||||
0: 0,
|
||||
};
|
||||
|
||||
export const speakingReverseMarking: {[key: number]: number} = {
|
||||
export const speakingReverseMarking: { [key: number]: number } = {
|
||||
9: 90,
|
||||
8.5: 85,
|
||||
8: 80,
|
||||
@@ -47,7 +47,7 @@ export const speakingReverseMarking: {[key: number]: number} = {
|
||||
0: 0,
|
||||
};
|
||||
|
||||
export const writingMarking: {[key: number]: number} = {
|
||||
export const writingMarking: { [key: number]: number } = {
|
||||
90: 9,
|
||||
80: 8,
|
||||
70: 7,
|
||||
@@ -60,7 +60,7 @@ export const writingMarking: {[key: number]: number} = {
|
||||
0: 0,
|
||||
};
|
||||
|
||||
const readingGeneralMarking: {[key: number]: number} = {
|
||||
const readingGeneralMarking: { [key: number]: number } = {
|
||||
100: 9,
|
||||
97.5: 8.5,
|
||||
92.5: 8,
|
||||
@@ -77,7 +77,7 @@ const readingGeneralMarking: {[key: number]: number} = {
|
||||
15: 2.5,
|
||||
};
|
||||
|
||||
const academicMarking: {[key: number]: number} = {
|
||||
const academicMarking: { [key: number]: number } = {
|
||||
97.5: 9,
|
||||
92.5: 8.5,
|
||||
87.5: 8,
|
||||
@@ -94,7 +94,7 @@ const academicMarking: {[key: number]: number} = {
|
||||
10: 2.5,
|
||||
};
|
||||
|
||||
const levelMarking: {[key: number]: number} = {
|
||||
const levelMarking: { [key: number]: number } = {
|
||||
88: 9, // Advanced
|
||||
64: 8, // Upper-Intermediate
|
||||
52: 6, // Intermediate
|
||||
@@ -103,7 +103,7 @@ const levelMarking: {[key: number]: number} = {
|
||||
0: 0, // Beginner
|
||||
};
|
||||
|
||||
const moduleMarkings: {[key in Module | "overall"]: {[key in Type]: {[key: number]: number}}} = {
|
||||
const moduleMarkings: { [key in Module | "overall"]: { [key in Type]: { [key: number]: number } } } = {
|
||||
reading: {
|
||||
academic: academicMarking,
|
||||
general: readingGeneralMarking,
|
||||
@@ -147,7 +147,7 @@ export const calculateBandScore = (correct: number, total: number, module: Modul
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
|
||||
export const calculateAverageLevel = (levels: { [key in Module]: number }) => {
|
||||
return (
|
||||
Object.keys(levels)
|
||||
.filter((x) => x !== "level")
|
||||
@@ -193,20 +193,21 @@ 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} = {
|
||||
const levels: { [key in Module]: number } = {
|
||||
reading: 0,
|
||||
listening: 0,
|
||||
writing: 0,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {WithLabeledEntities} from "@/interfaces/entity";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {capitalize} from "lodash";
|
||||
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { capitalize } from "lodash";
|
||||
import moment from "moment";
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
export interface UserListRow {
|
||||
name: string;
|
||||
@@ -17,6 +18,22 @@ export interface UserListRow {
|
||||
gender: string;
|
||||
}
|
||||
|
||||
const indexToLetter = (index: number): string => {
|
||||
// Base case: if the index is less than 0, return an empty string
|
||||
if (index < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Calculate the quotient for recursion (number of times the letter sequence repeats)
|
||||
const quotient = Math.floor(index / 26);
|
||||
|
||||
// Calculate the remainder for the current letter
|
||||
const remainder = index % 26;
|
||||
|
||||
// Recursively call indexToLetter for the quotient and append the current letter
|
||||
return indexToLetter(quotient - 1) + String.fromCharCode(65 + remainder);
|
||||
};
|
||||
|
||||
export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
|
||||
const rows: UserListRow[] = rowUsers.map((user) => ({
|
||||
name: user.name,
|
||||
@@ -33,10 +50,31 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
|
||||
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
|
||||
verified: user.isVerified?.toString() || "FALSE",
|
||||
}));
|
||||
const header = "Name,Email,Type,Entities,Expiry Date,Country,Phone,Employment/Department,Gender,Verification";
|
||||
const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet("User Data");
|
||||
const border: Partial<ExcelJS.Borders> = { top: { style: 'thin' as ExcelJS.BorderStyle }, left: { style: 'thin' as ExcelJS.BorderStyle }, bottom: { style: 'thin' as ExcelJS.BorderStyle }, right: { style: 'thin' as ExcelJS.BorderStyle } }
|
||||
const header = ['Name', 'Email', 'Type', 'Entities', 'Expiry Date', 'Country', 'Phone', 'Employment/Department', 'Gender', 'Verification'].forEach((item, index) => {
|
||||
const cell = worksheet.getCell(`${indexToLetter(index)}1`);
|
||||
const column = worksheet.getColumn(index + 1);
|
||||
column.width = item.length * 2;
|
||||
cell.value = item;
|
||||
cell.font = { bold: true, size: 16 };
|
||||
cell.border = border;
|
||||
|
||||
return `${header}\n${rowsString}`;
|
||||
});
|
||||
rows.forEach((x, index) => {
|
||||
(Object.keys(x) as (keyof UserListRow)[]).forEach((key, i) => {
|
||||
const cell = worksheet.getCell(`${indexToLetter(i)}${index + 2}`);
|
||||
cell.value = x[key];
|
||||
if (index === 0) {
|
||||
const column = worksheet.getColumn(i + 1);
|
||||
column.width = Math.max(column.width ?? 0, x[key].toString().length * 2);
|
||||
}
|
||||
cell.border = border;
|
||||
});
|
||||
})
|
||||
|
||||
return workbook.xlsx.writeBuffer();
|
||||
};
|
||||
|
||||
export const getUserName = (user?: User) => {
|
||||
|
||||
Reference in New Issue
Block a user