Compare commits

..

43 Commits

Author SHA1 Message Date
Tiago Ribeiro
d7a8f496c0 Merged develop into approval-workflows 2025-03-04 01:43:32 +00:00
Joao Correia
5e363e9951 Merge branch 'approval-workflows' of bitbucket.org:ecropdev/ielts-ui into approval-workflows 2025-03-04 00:34:17 +00:00
Joao Correia
3370f3c648 add approved field to exam 2025-03-04 00:33:09 +00:00
João Correia
d77336374d Merged in approval-workflows (pull request #156)
Approval workflows

Approved-by: Tiago Ribeiro
2025-03-03 11:17:40 +00:00
Tiago Ribeiro
e765dea106 Merged develop into approval-workflows 2025-03-03 11:17:17 +00:00
Joao Correia
75fb9490e0 some more slight improvements to exam changes logs 2025-03-02 14:27:17 +00:00
Joao Correia
3ef7998193 order workflows table in descent startDate 2025-03-02 00:21:30 +00:00
Joao Correia
32cd8495d6 add imutable ids to some exam arrays to detect and log changes between two exams. 2025-03-02 00:10:57 +00:00
Joao Correia
4e3cfec9e8 change to a single checkbox filter for all modules 2025-02-27 10:29:35 +00:00
Joao Correia
ba8cc342b1 add filters to show only exams with or without approval 2025-02-26 19:15:20 +00:00
Joao Correia
dd8f821e35 only show workflows where user is assigned to at least one step. 2025-02-26 17:21:37 +00:00
Joao Correia
a4ef2222e2 Keep exam confidential even after approval workflow is completed 2025-02-26 16:51:57 +00:00
Joao Correia
93d9e49358 Merge branch 'develop' into approval-workflows 2025-02-26 16:42:09 +00:00
Francisco Lima
5d0a3acbee Merged in bugfixes-generationdesignchanges (pull request #155)
bugsfixed and design changes for generation 13'' screen

Approved-by: Tiago Ribeiro
2025-02-24 13:38:54 +00:00
José Lima
340ff5a30a bugsfixed and design changes for generation 13'' screen 2025-02-23 18:47:57 +00:00
João Correia
37908423eb Merged in approval-workflows (pull request #154)
Approval Workflows

Approved-by: Tiago Ribeiro
2025-02-20 14:30:24 +00:00
Joao Correia
b388ee399f small refactor 2025-02-20 12:12:00 +00:00
Joao Correia
4ac11df6ae fix examId being cleared when editing approval workflow 2025-02-20 11:27:44 +00:00
Joao Correia
14e2702aca add error message and stop loading if something went wrong while loading exam in approval workflow 2025-02-20 10:40:31 +00:00
Tiago Ribeiro
fec3b51553 Created two new permissions 2025-02-17 10:32:57 +00:00
João Correia
d8386bdd8e Merged in approval-workflows (pull request #152)
Approval workflows

Approved-by: Tiago Ribeiro
2025-02-11 12:09:17 +00:00
Joao Correia
df2f83e496 make access confidential when user submits exam with approval process. make access private upon approval workflow completed. 2025-02-10 13:25:11 +00:00
Joao Correia
e214d8b598 improve edited exam changes again 2025-02-10 11:30:24 +00:00
Joao Correia
c14f16c97a improve edited exam changes printing format 2025-02-09 21:12:29 +00:00
Joao Correia
ca2cf739ee improve edited exam changes printing format 2025-02-09 20:56:55 +00:00
João Correia
d432fb4bc4 Merged in approval-workflows (pull request #151)
Approval workflows

Approved-by: Tiago Ribeiro
2025-02-09 18:44:44 +00:00
Joao Correia
d5bffc9bad Add pagination to approval workflows table and change module styling to match project colors. 2025-02-09 18:10:59 +00:00
Joao Correia
75b4643918 Add button to submit exam without approval process 2025-02-09 17:37:19 +00:00
Joao Correia
9ae6b8e894 make sure admin id is passed to step component if the admin is not assigned to the workflow but approved a step. 2025-02-09 16:55:50 +00:00
Joao Correia
6f6c5a4209 make first step approved by default 2025-02-09 15:44:37 +00:00
Tiago Ribeiro
769b1b39d3 Added the permission 2025-02-09 11:35:52 +00:00
Francisco Lima
4bb12c7f01 Merged in addedAccess-bugfixes (pull request #150)
AddedAccess bugfixes

Approved-by: Tiago Ribeiro
2025-02-09 11:16:24 +00:00
Francisco Lima
a80a342ae2 Merged develop into addedAccess-bugfixes 2025-02-09 04:32:42 +00:00
José Lima
e5e60fcce9 fixed some issues related to build 2025-02-09 04:29:32 +00:00
José Lima
b175d8797e added access variable to exams soo we can distinguish private, public and confidential exams and also bugfixes and improvements 2025-02-09 04:28:34 +00:00
João Correia
f06349e350 Merged in approval-workflows (pull request #149)
filter workflows user can see based on entities

Approved-by: Tiago Ribeiro
2025-02-08 19:35:46 +00:00
Tiago Ribeiro
34caf9986c Merged develop into approval-workflows 2025-02-08 19:35:31 +00:00
Joao Correia
3a3d3d014d filter workflows user can see based on entities 2025-02-08 19:23:42 +00:00
João Correia
c49c303f20 Merged in approval-workflows (pull request #148)
temporary fix for same exam instance being used for all entities and implement approval process skip edge cases

Approved-by: Tiago Ribeiro
2025-02-08 18:03:43 +00:00
Joao Correia
cbe353c2c5 - start only one of the configured workflows (randomly at the moment) for the exam Author.
- skip approval process for admins
2025-02-08 15:26:16 +00:00
Tiago Ribeiro
991adede96 Merged in develop (pull request #147)
Develop
2025-02-07 17:54:57 +00:00
Tiago Ribeiro
f95bce6fa2 Did some fixes related to master corporates 2025-02-07 16:19:47 +00:00
João Correia
1dd6cead9e Merged in workflow-permissions (pull request #146)
Workflow permissions

Approved-by: Tiago Ribeiro
2025-02-07 15:43:34 +00:00
75 changed files with 4390 additions and 3144 deletions

View 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

View File

@@ -1,17 +1,12 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import {getExam, getExamById} from "@/utils/exams"; import {getExam} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import { useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs"; import { BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "./Low/Button"; import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector"; import ModuleLevelSelector from "./Medium/ModuleLevelSelector";

View File

@@ -13,6 +13,7 @@ import validateBlanks from "../validateBlanks";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert"; import setEditingAlert from "../../Shared/setEditingAlert";
import PromptEdit from "../../Shared/PromptEdit"; import PromptEdit from "../../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
interface Word { interface Word {
letter: string; letter: string;
@@ -72,6 +73,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
...local, ...local,
text: blanksState.text, text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({ solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -145,6 +147,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
setLocal(prev => ({ setLocal(prev => ({
...prev, ...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -189,6 +192,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
...prev, ...prev,
words: newWords, words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -217,6 +221,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
...prev, ...prev,
words: newWords, words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -234,6 +239,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
setLocal(prev => ({ setLocal(prev => ({
...prev, ...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))

View File

@@ -11,6 +11,7 @@ import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert"; import setEditingAlert from "../../Shared/setEditingAlert";
import { MdEdit, MdEditOff } from "react-icons/md"; import { MdEdit, MdEditOff } from "react-icons/md";
import MCOption from "./MCOption"; import MCOption from "./MCOption";
import { uuidv4 } from "@firebase/util";
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
@@ -69,6 +70,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...local, ...local,
text: blanksState.text, text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({ solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -139,6 +141,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
setLocal(prev => ({ setLocal(prev => ({
...prev, ...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -168,6 +171,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...prev, ...prev,
words: newWords, words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -217,6 +221,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...prev, ...prev,
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()), words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))
@@ -234,6 +239,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
blanksMissingWords.forEach(blank => { blanksMissingWords.forEach(blank => {
const newMCOption: FillBlanksMCOption = { const newMCOption: FillBlanksMCOption = {
uuid: uuidv4(),
id: blank.id.toString(), id: blank.id.toString(),
options: { options: {
A: 'Option A', A: 'Option A',
@@ -249,6 +255,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...prev, ...prev,
words: newWords, words: newWords,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({ solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id, id,
solution solution
})) }))

View File

@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
import { DragEndEvent } from '@dnd-kit/core'; import { DragEndEvent } from '@dnd-kit/core';
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local'; import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit'; import PromptEdit from '../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => { const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
@@ -98,6 +99,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
sentences: [ sentences: [
...local.sentences, ...local.sentences,
{ {
uuid: uuidv4(),
id: newId, id: newId,
sentence: "", sentence: "",
solution: "" solution: ""

View File

@@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
import { MdAdd } from "react-icons/md"; import { MdAdd } from "react-icons/md";
import Alert, { AlertItem } from "../../Shared/Alert"; import Alert, { AlertItem } from "../../Shared/Alert";
import PromptEdit from "../../Shared/PromptEdit"; import PromptEdit from "../../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
@@ -57,6 +58,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
{ {
prompt: "", prompt: "",
solution: "", solution: "",
uuid: uuidv4(),
id: newId, id: newId,
options, options,
variant: "text" variant: "text"

View File

@@ -18,6 +18,7 @@ import SortableQuestion from '../../Shared/SortableQuestion';
import setEditingAlert from '../../Shared/setEditingAlert'; import setEditingAlert from '../../Shared/setEditingAlert';
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local'; import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../../Shared/PromptEdit'; import PromptEdit from '../../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
interface MultipleChoiceProps { interface MultipleChoiceProps {
exercise: MultipleChoiceExercise; exercise: MultipleChoiceExercise;
@@ -120,6 +121,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
{ {
prompt: "", prompt: "",
solution: "", solution: "",
uuid: uuidv4(),
id: newId, id: newId,
options, options,
variant: "text" variant: "text"

View File

@@ -16,6 +16,7 @@ import setEditingAlert from '../Shared/setEditingAlert';
import { DragEndEvent } from '@dnd-kit/core'; import { DragEndEvent } from '@dnd-kit/core';
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local'; import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit'; import PromptEdit from '../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => { const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
@@ -50,6 +51,7 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
{ {
prompt: "", prompt: "",
solution: undefined, solution: undefined,
uuid: uuidv4(),
id: newId id: newId
} }
] ]

View File

@@ -22,6 +22,7 @@ import { validateEmptySolutions, validateQuestionText, validateWordCount } from
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local'; import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
import { ParsedQuestion, parseText, reconstructText } from './parsing'; import { ParsedQuestion, parseText, reconstructText } from './parsing';
import PromptEdit from '../Shared/PromptEdit'; import PromptEdit from '../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => { 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 newId = (Math.max(...existingIds, 0) + 1).toString();
const newQuestion = { const newQuestion = {
uuid: uuidv4(),
id: newId, id: newId,
questionText: "New question" questionText: "New question"
}; };
@@ -113,6 +115,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
const updatedText = reconstructText(updatedQuestions); const updatedText = reconstructText(updatedQuestions);
const updatedSolutions = [...local.solutions, { const updatedSolutions = [...local.solutions, {
uuid: uuidv4(),
id: newId, id: newId,
solution: [""] solution: [""]
}]; }];

View File

@@ -17,6 +17,7 @@ import { validateQuestions, validateEmptySolutions, validateWordCount } from "./
import Header from "../../Shared/Header"; import Header from "../../Shared/Header";
import BlanksFormEditor from "./BlanksFormEditor"; import BlanksFormEditor from "./BlanksFormEditor";
import PromptEdit from "../Shared/PromptEdit"; import PromptEdit from "../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => { 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 newLine = `New question with blank {{${newId}}}`;
const updatedQuestions = [...parsedQuestions, { const updatedQuestions = [...parsedQuestions, {
uuid: uuidv4(),
id: newId, id: newId,
parts: parseLine(newLine), parts: parseLine(newLine),
editingPlaceholders: true editingPlaceholders: true
@@ -121,6 +123,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
.join('\\n') + '\\n'; .join('\\n') + '\\n';
const updatedSolutions = [...local.solutions, { const updatedSolutions = [...local.solutions, {
uuid: uuidv4(),
id: newId, id: newId,
solution: [""] solution: [""]
}]; }];

View File

@@ -19,7 +19,7 @@ interface SettingsEditorProps {
children?: ReactNode; children?: ReactNode;
canPreview: boolean; canPreview: boolean;
canSubmit: boolean; canSubmit: boolean;
submitModule: () => void; submitModule: (requiresApproval: boolean) => void;
preview: () => void; preview: () => void;
} }
@@ -148,18 +148,33 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
</div> </div>
</Dropdown> </Dropdown>
{children} {children}
<div className="flex flex-row justify-between mt-4"> <div className="flex flex-col gap-3 mt-4">
<button <button
className={clsx( className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300", "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`, `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" "disabled:cursor-not-allowed disabled:text-gray-200"
)} )}
onClick={submitModule} onClick={() => submitModule(true)}
disabled={!canSubmit} disabled={!canSubmit}
> >
<FaFileUpload className="mr-2" size={18} /> <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>
<button <button
className={clsx( className={clsx(
@@ -171,7 +186,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
disabled={!canPreview} disabled={!canPreview}
> >
<FaEye className="mr-2" size={18} /> <FaEye className="mr-2" size={18} />
Preview Module Preview module
</button> </button>
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@ const LevelSettings: React.FC = () => {
difficulty, difficulty,
sections, sections,
minTimer, minTimer,
isPrivate, access,
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
@@ -76,7 +76,7 @@ const LevelSettings: React.FC = () => {
}); });
}); });
const submitLevel = async () => { const submitLevel = async (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -195,12 +195,13 @@ const LevelSettings: React.FC = () => {
category: s.settings.category category: s.settings.category
}; };
}).filter(part => part.exercises.length > 0), }).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, minTimer,
module: "level", module: "level",
id: title, id: title,
difficulty, difficulty,
private: isPrivate, access,
}; };
const result = await axios.post('/api/exam/level', exam); const result = await axios.post('/api/exam/level', exam);
@@ -243,7 +244,7 @@ const LevelSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
} as LevelExam); } as LevelExam);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);

View File

@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''} 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"> <div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label> <label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input <Input

View File

@@ -1,15 +1,9 @@
import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker";
import SettingsEditor from ".."; import SettingsEditor from "..";
import GenerateBtn from "../Shared/GenerateBtn"; import { ListeningSectionSettings } from "@/stores/examEditor/types";
import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate";
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../../Hooks/useSettingsState"; import useSettingsState from "../../Hooks/useSettingsState";
import { ListeningExam, ListeningPart } from "@/interfaces/exam"; import { ListeningExam, ListeningPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import axios from "axios"; import axios from "axios";
@@ -17,7 +11,6 @@ import { usePersistentExamStore } from "@/stores/exam";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import ListeningComponents from "./components"; import ListeningComponents from "./components";
import { getExamById } from "@/utils/exams";
const ListeningSettings: React.FC = () => { const ListeningSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();
@@ -27,7 +20,7 @@ const ListeningSettings: React.FC = () => {
difficulty, difficulty,
sections, sections,
minTimer, minTimer,
isPrivate, access,
instructionsState instructionsState
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
@@ -65,7 +58,7 @@ const ListeningSettings: React.FC = () => {
} }
]; ];
const submitListening = async () => { const submitListening = async (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -138,13 +131,14 @@ const ListeningSettings: React.FC = () => {
category: s.settings.category category: s.settings.category
}; };
}), }),
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer, minTimer,
module: "listening", module: "listening",
id: title, id: title,
variant: sections.length === 4 ? "full" : "partial", variant: sections.length === 4 ? "full" : "partial",
difficulty, difficulty,
private: isPrivate, access,
instructions: instructionsURL instructions: instructionsURL
}; };
@@ -191,7 +185,7 @@ const ListeningSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: sections.length === 4 ? "full" : "partial", variant: sections.length === 4 ? "full" : "partial",
difficulty, difficulty,
private: isPrivate, access,
instructions: instructionsState.currentInstructionsURL instructions: instructionsState.currentInstructionsURL
} as ListeningExam); } as ListeningExam);
setExerciseIndex(0); setExerciseIndex(0);

View File

@@ -82,7 +82,7 @@ const ReadingComponents: React.FC<Props> = ({
disabled={generatePassageDisabled} disabled={generatePassageDisabled}
> >
<div <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"> <div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">

View File

@@ -12,7 +12,6 @@ import axios from "axios";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import ReadingComponents from "./components"; import ReadingComponents from "./components";
import { getExamById } from "@/utils/exams";
const ReadingSettings: React.FC = () => { const ReadingSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();
@@ -26,43 +25,41 @@ const ReadingSettings: React.FC = () => {
} = usePersistentExamStore(); } = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore(); const { currentModule, title } = useExamEditorStore();
const { const { focusedSection, difficulty, sections, minTimer, access, type } =
focusedSection, useExamEditorStore((state) => state.modules[currentModule]);
difficulty,
sections,
minTimer,
isPrivate,
type,
} = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } =
currentModule, useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
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[] = [ const defaultPresets: Option[] = [
{ {
label: "Preset: Reading Passage 1", 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." 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", 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." 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", 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." 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( const canPreviewOrSubmit = sections.some(
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0 (s) =>
(s.state as ReadingPart).exercises &&
(s.state as ReadingPart).exercises.length > 0
); );
const submitReading = () => { const submitReading = (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -73,20 +70,22 @@ const ReadingSettings: React.FC = () => {
return { return {
...exercise, ...exercise,
intro: localSettings.currentIntro, intro: localSettings.currentIntro,
category: localSettings.category category: localSettings.category,
}; };
}), }),
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer, minTimer,
module: "reading", module: "reading",
id: title, id: title,
variant: sections.length === 3 ? "full" : "partial", variant: sections.length === 3 ? "full" : "partial",
difficulty, difficulty,
private: isPrivate, access,
type: type! type: type!,
}; };
axios.post(`/api/exam/reading`, exam) axios
.post(`/api/exam/reading`, exam)
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
// Successfully submitted exam // Successfully submitted exam
@@ -98,9 +97,12 @@ const ReadingSettings: React.FC = () => {
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); toast.error(
}) error.response.data.error ||
} "Something went wrong while submitting, please try again later."
);
});
};
const preview = () => { const preview = () => {
setExam({ setExam({
@@ -109,7 +111,7 @@ const ReadingSettings: React.FC = () => {
return { return {
...exercises, ...exercises,
intro: s.settings.currentIntro, intro: s.settings.currentIntro,
category: s.settings.category category: s.settings.category,
}; };
}), }),
minTimer, minTimer,
@@ -118,15 +120,15 @@ const ReadingSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access: access,
type: type! type: type!,
} as ReadingExam); } as ReadingExam);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);
setPartIndex(0); setPartIndex(0);
setBgColor("bg-white"); setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=reading", router) openDetachedTab("popout?type=Exam&module=reading", router);
} };
return ( return (
<SettingsEditor <SettingsEditor

View File

@@ -30,7 +30,7 @@ const SpeakingSettings: React.FC = () => {
} = usePersistentExamStore(); } = usePersistentExamStore();
const { title, currentModule } = useExamEditorStore(); 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; 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 === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -181,11 +181,12 @@ const SpeakingSettings: React.FC = () => {
minTimer, minTimer,
module: "speaking", module: "speaking",
id: title, id: title,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
instructorGender: "varied", instructorGender: "varied",
private: isPrivate, access,
}; };
const result = await axios.post('/api/exam/speaking', exam); const result = await axios.post('/api/exam/speaking', exam);
@@ -238,7 +239,7 @@ const SpeakingSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
} as SpeakingExam); } as SpeakingExam);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);

View File

@@ -23,7 +23,7 @@ const WritingSettings: React.FC = () => {
const { const {
minTimer, minTimer,
difficulty, difficulty,
isPrivate, access,
sections, sections,
focusedSection, focusedSection,
type, type,
@@ -81,14 +81,14 @@ const WritingSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
type: type! type: type!
}); });
setExerciseIndex(0); setExerciseIndex(0);
openDetachedTab("popout?type=Exam&module=writing", router) openDetachedTab("popout?type=Exam&module=writing", router)
} }
const submitWriting = async () => { const submitWriting = async (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -131,10 +131,11 @@ const WritingSettings: React.FC = () => {
minTimer, minTimer,
module: "writing", module: "writing",
id: title, id: title,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
type: type! type: type!
}; };

View File

@@ -1,14 +1,13 @@
import clsx from "clsx"; import clsx from "clsx";
import SectionRenderer from "./SectionRenderer"; import SectionRenderer from "./SectionRenderer";
import Checkbox from "../Low/Checkbox";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import {Difficulty} from "@/interfaces/exam"; import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
import {useCallback, useEffect, useMemo, useState} from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {ModuleState, SectionState} from "@/stores/examEditor/types"; import { ModuleState, SectionState } from "@/stores/examEditor/types";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import WritingSettings from "./SettingsEditor/writing"; import WritingSettings from "./SettingsEditor/writing";
import ReadingSettings from "./SettingsEditor/reading"; import ReadingSettings from "./SettingsEditor/reading";
@@ -16,24 +15,56 @@ import LevelSettings from "./SettingsEditor/level";
import ListeningSettings from "./SettingsEditor/listening"; import ListeningSettings from "./SettingsEditor/listening";
import SpeakingSettings from "./SettingsEditor/speaking"; import SpeakingSettings from "./SettingsEditor/speaking";
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch"; import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
import {defaultSectionSettings} from "@/stores/examEditor/defaults"; import { defaultSectionSettings } from "@/stores/examEditor/defaults";
import Button from "../Low/Button"; import Button from "../Low/Button";
import ResetModule from "./Standalone/ResetModule"; import ResetModule from "./Standalone/ResetModule";
import ListeningInstructions from "./Standalone/ListeningInstructions"; 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[]}> = ({ 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, levelParts = 0,
entitiesAllowEditPrivacy = [], entitiesAllowEditPrivacy = [],
entitiesAllowConfExams = [],
entitiesAllowPublicExams = [],
}) => { }) => {
const {currentModule, dispatch} = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore( const {
(state) => state.modules[currentModule], sections,
); minTimer,
expandedSections,
examLabel,
access,
difficulty,
sectionLabels,
importModule,
} = useExamEditorStore((state) => state.modules[currentModule]);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1); const [numberOfLevelParts, setNumberOfLevelParts] = useState(
levelParts !== 0 ? levelParts : 1
);
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
// For exam edits // For exam edits
@@ -44,7 +75,7 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
type: "UPDATE_MODULE", type: "UPDATE_MODULE",
payload: { payload: {
updates: { updates: {
sectionLabels: Array.from({length: levelParts}).map((_, i) => ({ sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
id: i + 1, id: i + 1,
label: `Part ${i + 1}`, label: `Part ${i + 1}`,
})), })),
@@ -61,11 +92,16 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
const currentLabels = sectionLabels; const currentLabels = sectionLabels;
let updatedSections: SectionState[]; let updatedSections: SectionState[];
let updatedLabels: any; let updatedLabels: any;
if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) { if (
(currentModule === "level" &&
currentSections.length !== currentLabels.length) ||
numberOfLevelParts !== currentSections.length
) {
const newSections = [...currentSections]; const newSections = [...currentSections];
const newLabels = [...currentLabels]; const newLabels = [...currentLabels];
for (let i = currentLabels.length; i < numberOfLevelParts; i++) { for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1)); if (currentSections.length !== numberOfLevelParts)
newSections.push(defaultSectionSettings(currentModule, i + 1));
newLabels.push({ newLabels.push({
id: i + 1, id: i + 1,
label: `Part ${i + 1}`, label: `Part ${i + 1}`,
@@ -80,7 +116,9 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
return; return;
} }
const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId)); const updatedExpandedSections = expandedSections.filter((sectionId) =>
updatedSections.some((section) => section.sectionId === sectionId)
);
dispatch({ dispatch({
type: "UPDATE_MODULE", type: "UPDATE_MODULE",
@@ -95,42 +133,62 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]); }, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId); const sectionIds = useMemo(
() => sections.map((section) => section.sectionId),
[sections]
);
const updateModule = useCallback( const updateModule = useCallback(
(updates: Partial<ModuleState>) => { (updates: Partial<ModuleState>) => {
dispatch({type: "UPDATE_MODULE", payload: {updates}}); dispatch({ type: "UPDATE_MODULE", payload: { updates } });
}, },
[dispatch], [dispatch]
); );
const toggleSection = (sectionId: number) => { const toggleSection = useCallback(
(sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
toast.error("Include at least one section!"); toast.error("Include at least one section!");
return; return;
} }
dispatch({type: "TOGGLE_SECTION", payload: {sectionId}}); dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
}; },
[dispatch, expandedSections, sectionIds]
);
const ModuleSettings: Record<Module, React.ComponentType> = { const Settings = useMemo(
reading: ReadingSettings, () => ModuleSettings[currentModule],
writing: WritingSettings, [currentModule]
speaking: SpeakingSettings, );
listening: ListeningSettings,
level: LevelSettings,
};
const Settings = ModuleSettings[currentModule]; const showImport = useMemo(
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule); () =>
importModule && ["reading", "listening", "level"].includes(currentModule),
[importModule, currentModule]
);
const updateLevelParts = (parts: number) => { const accessTypeOptions = useMemo(() => {
let options: Option[] = [{ value: "private", label: "Private" }];
if (entitiesAllowConfExams.length > 0) {
options.push({ value: "confidential", label: "Confidential" });
}
if (entitiesAllowPublicExams.length > 0) {
options.push({ value: "public", label: "Public" });
}
return options;
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
const updateLevelParts = useCallback((parts: number) => {
setNumberOfLevelParts(parts); setNumberOfLevelParts(parts);
}; }, []);
return ( return (
<> <>
{showImport ? ( {showImport ? (
<ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> <ImportOrStartFromScratch
module={currentModule}
setNumberOfLevelParts={updateLevelParts}
/>
) : ( ) : (
<> <>
{isResetModuleOpen && ( {isResetModuleOpen && (
@@ -141,10 +199,17 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
setNumberOfLevelParts={setNumberOfLevelParts} setNumberOfLevelParts={setNumberOfLevelParts}
/> />
)} )}
<div className="flex gap-4 w-full items-center -xl:flex-col"> <div
<div className="flex flex-row gap-3 w-full"> className={clsx(
<div className="flex flex-col gap-3"> "flex gap-4 w-full",
<label className="font-normal text-base text-mti-gray-dim">Timer</label> 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 <Input
type="number" type="number"
name="minTimer" name="minTimer"
@@ -154,24 +219,28 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
}) })
} }
value={minTimer} value={minTimer}
className="max-w-[300px]" className="max-w-[125px] min-w-[100px] w-min"
/> />
</div> </div>
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 ">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label> <label className="font-normal text-base text-mti-gray-dim">
Difficulty
</label>
<Select <Select
isMulti={true} isMulti={true}
options={DIFFICULTIES.map((x) => ({ options={DIFFICULTIES}
value: x,
label: capitalize(x),
}))}
onChange={(values) => { onChange={(values) => {
const selectedDifficulties = values ? values.map((v) => v.value as Difficulty) : []; const selectedDifficulties = values
updateModule({difficulty: selectedDifficulties}); ? values.map((v) => v.value as Difficulty)
: [];
updateModule({ difficulty: selectedDifficulties });
}} }}
value={ value={
difficulty difficulty
? difficulty.map((d) => ({ ? (Array.isArray(difficulty)
? difficulty
: [difficulty]
).map((d) => ({
value: d, value: d,
label: capitalize(d), label: capitalize(d),
})) }))
@@ -182,19 +251,22 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
</div> </div>
{sectionLabels.length != 0 && currentModule !== "level" ? ( {sectionLabels.length != 0 && currentModule !== "level" ? (
<div className="flex flex-col gap-3 -xl:w-full"> <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> <label className="font-normal text-base text-mti-gray-dim">
<div className="flex flex-row gap-8"> {sectionLabels[0].label.split(" ")[0]}
{sectionLabels.map(({id, label}) => ( </label>
<div className="flex flex-row gap-3">
{sectionLabels.map(({ id, label }) => (
<span <span
key={id} key={id}
className={clsx( className={clsx(
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
sectionIds.includes(id) sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white` ? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
: "bg-white border-mti-gray-platinum", : "bg-white border-mti-gray-platinum"
)} )}
onClick={() => toggleSection(id)}> onClick={() => toggleSection(id)}
>
{label} {label}
</span> </span>
))} ))}
@@ -202,7 +274,9 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3 w-1/3"> <div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label> <label className="font-normal text-base text-mti-gray-dim">
Number of Parts
</label>
<Input <Input
type="number" type="number"
name="Number of Parts" name="Number of Parts"
@@ -212,24 +286,34 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
/> />
</div> </div>
)} )}
<div className="flex flex-col gap-3 w-fit h-fit"> <div className="max-w-[200px] w-full">
<div className="h-6" /> <Select
<Checkbox label="Access Type"
isChecked={isPrivate} disabled={
onChange={(checked) => updateModule({isPrivate: checked})} accessTypeOptions.length === 0 ||
disabled={entitiesAllowEditPrivacy.length === 0}> entitiesAllowEditPrivacy.length === 0
Privacy (Only available for Assignments) }
</Checkbox> options={accessTypeOptions}
onChange={(value) => {
if (value?.value) {
updateModule({ access: value.value! as AccessType });
}
}}
value={{ value: access, label: capitalize(access) }}
/>
</div> </div>
</div> </div>
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label> <label className="font-normal text-base text-mti-gray-dim">
Exam Label *
</label>
<Input <Input
type="text" type="text"
placeholder="Exam Label" placeholder="Exam Label"
name="label" name="label"
onChange={(text) => updateModule({examLabel: text})} onChange={(text) => updateModule({ examLabel: text })}
roundness="xl" roundness="xl"
value={examLabel} value={examLabel}
required required
@@ -239,11 +323,12 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
<Button <Button
onClick={() => setIsResetModuleOpen(true)} onClick={() => setIsResetModuleOpen(true)}
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`} customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
className={`text-white self-end`}> className={`text-white self-end`}
>
Reset Module Reset Module
</Button> </Button>
</div> </div>
<div className="flex flex-row gap-8 -2xl:flex-col"> <div className="flex flex-row gap-8 -xl:flex-col">
<Settings /> <Settings />
<div className="flex-grow max-w-[66%] -2xl:max-w-full"> <div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer /> <SectionRenderer />

View File

@@ -149,10 +149,16 @@ export default function Table<T>({
))} ))}
</tbody> </tbody>
</table> </table>
{isLoading && ( {isLoading ? (
<div className="min-h-screen flex justify-center items-start"> <div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" /> <span className="loading loading-infinity w-32" />
</div> </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> </div>
); );

View File

@@ -1,9 +1,7 @@
import {useListSearch} from "@/hooks/useListSearch"; import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; 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 clsx from "clsx";
import {useMemo, useState} from "react";
import Button from "./Low/Button";
const SIZE = 25; const SIZE = 25;

View File

@@ -3,8 +3,6 @@ import { checkAccess } from "@/utils/permissions";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { ReactNode, useEffect, useMemo, useState } from "react"; import { ReactNode, useEffect, useMemo, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
@@ -44,13 +42,13 @@ const RecordFilter: React.FC<Props> = ({
const [entity, setEntity] = useState<string>(); const [entity, setEntity] = useState<string>();
const [, setStatsUserId] = useRecordStore((state) => [ const [selectedUser, setStatsUserId] = useRecordStore((state) => [
state.selectedUser, state.selectedUser,
state.setSelectedUser, state.setSelectedUser,
]); ]);
const entitiesToSearch = useMemo(() => { const entitiesToSearch = useMemo(() => {
if(entity) return entity if (entity) return entity;
if (isAdmin) return undefined; if (isAdmin) return undefined;
return mapBy(entities, "id"); return mapBy(entities, "id");
}, [entities, entity, isAdmin]); }, [entities, entity, isAdmin]);
@@ -69,6 +67,14 @@ const RecordFilter: React.FC<Props> = ({
"view_student_record" "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]); useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
@@ -118,10 +124,7 @@ const RecordFilter: React.FC<Props> = ({
loadOptions={loadOptions} loadOptions={loadOptions}
onMenuScrollToBottom={onScrollLoadMoreOptions} onMenuScrollToBottom={onScrollLoadMoreOptions}
options={users} options={users}
defaultValue={{ defaultValue={selectedUserValue}
value: user.id,
label: `${user.name} - ${user.email}`,
}}
onChange={(value) => setStatsUserId(value?.value!)} onChange={(value) => setStatsUserId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),

View File

@@ -12,7 +12,6 @@ import { useRouter } from "next/router";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { getExamById } from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import { Exam, UserSolution } from "@/interfaces/exam";
import ModuleBadge from "../ModuleBadge"; import ModuleBadge from "../ModuleBadge";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { findBy } from "@/utils"; import { findBy } from "@/utils";

View File

@@ -121,12 +121,12 @@ export default function Sidebar({
entities, entities,
"view_statistics" "view_statistics"
); );
const entitiesAllowPaymentRecord = useAllowedEntities( const entitiesAllowPaymentRecord = useAllowedEntities(
user, user,
entities, entities,
"view_payment_record" "view_payment_record"
); );
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions( const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
user, user,
entities, entities,
@@ -148,7 +148,7 @@ export default function Sidebar({
viewTickets: true, viewTickets: true,
viewClassrooms: true, viewClassrooms: true,
viewSettings: true, viewSettings: true,
viewPaymentRecord: true, viewPaymentRecords: true,
viewGeneration: true, viewGeneration: true,
viewApprovalWorkflows: true, viewApprovalWorkflows: true,
}; };
@@ -160,7 +160,7 @@ export default function Sidebar({
viewTickets: false, viewTickets: false,
viewClassrooms: false, viewClassrooms: false,
viewSettings: false, viewSettings: false,
viewPaymentRecord: false, viewPaymentRecords: false,
viewGeneration: false, viewGeneration: false,
viewApprovalWorkflows: false, viewApprovalWorkflows: false,
}; };
@@ -235,7 +235,7 @@ export default function Sidebar({
) && ) &&
entitiesAllowPaymentRecord.length > 0 entitiesAllowPaymentRecord.length > 0
) { ) {
sidebarPermissions["viewPaymentRecord"] = true; sidebarPermissions["viewPaymentRecords"] = true;
} }
return sidebarPermissions; return sidebarPermissions;
}, [ }, [
@@ -378,7 +378,6 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav <Nav
@@ -427,6 +426,16 @@ export default function Sidebar({
isMinimized isMinimized
/> />
)} )}
{sidebarPermissions["viewPaymentRecords"] && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized
/>
)}
{sidebarPermissions["viewSettings"] && ( {sidebarPermissions["viewSettings"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
@@ -459,7 +468,7 @@ export default function Sidebar({
)} )}
</div> </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 <div
role="button" role="button"
tabIndex={1} tabIndex={1}
@@ -483,7 +492,7 @@ export default function Sidebar({
tabIndex={1} tabIndex={1}
onClick={focusMode ? () => {} : logout} onClick={focusMode ? () => {} : logout}
className={clsx( 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" isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)} )}
> >

View File

@@ -2,7 +2,7 @@ import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
export default function useApprovalWorkflows() { export default function useApprovalWorkflows(entitiesString?: string) {
const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]); const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -10,7 +10,7 @@ export default function useApprovalWorkflows() {
const getData = useCallback(() => { const getData = useCallback(() => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<ApprovalWorkflow[]>(`/api/approval-workflows`) .get<ApprovalWorkflow[]>(`/api/approval-workflows`, {params: { entityIds: entitiesString }})
.then((response) => setWorkflows(response.data)) .then((response) => setWorkflows(response.data))
.catch((error) => { .catch((error) => {
setIsError(true); setIsError(true);

View File

@@ -1,6 +1,6 @@
import {Exam} from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
export default function useExams() { export default function useExams() {
const [exams, setExams] = useState<Exam[]>([]); const [exams, setExams] = useState<Exam[]>([]);
@@ -10,12 +10,12 @@ export default function useExams() {
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Exam[]>("/api/exam") .get<Exam[]>(`/api/exam`)
.then((response) => setExams(response.data)) .then((response) => setExams(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, []); useEffect(getData, []);
return {exams, isLoading, isError, reload: getData}; return { exams, isLoading, isError, reload: getData };
} }

View File

@@ -1,25 +1,71 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {useMemo, useState} from "react"; import { useEffect, useMemo, useState } from "react";
import {BiChevronLeft} from "react-icons/bi"; import {
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs"; BsChevronDoubleLeft,
BsChevronDoubleRight,
BsChevronLeft,
BsChevronRight,
} from "react-icons/bs";
import Select from "../components/Low/Select";
export default function usePagination<T>(list: T[], size = 25) { 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 itemsPerPageOptions = [25, 50, 100, 200];
const render = () => ( const render = () => (
<div className="w-full flex gap-2 justify-between items-center"> <div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}> <Button
className="w-[200px] h-fit"
disabled={page === 0}
onClick={() => setPage((prev) => prev - 1)}
>
Previous Page Previous Page
</Button> </Button>
</div> </div>
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<span className="opacity-80"> <div className="flex flex-row items-center gap-1 w-56">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} <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> </span>
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}> </div>
<Button
className="w-[200px]"
disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage((prev) => prev + 1)}
>
Next Page Next Page
</Button> </Button>
</div> </div>
@@ -29,32 +75,64 @@ export default function usePagination<T>(list: T[], size = 25) {
const renderMinimal = () => ( const renderMinimal = () => (
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed"> <button
disabled={page === 0}
onClick={() => setPage(0)}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronDoubleLeft /> <BsChevronDoubleLeft />
</button> </button>
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed"> <button
disabled={page === 0}
onClick={() => setPage((prev) => prev - 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronLeft /> <BsChevronLeft />
</button> </button>
</div> </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"> <span className="opacity-80 w-32 text-center">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} {page * itemsPerPage + 1} -{" "}
{itemsPerPage * (page + 1) > list.length
? list.length
: itemsPerPage * (page + 1)}
/ {list.length}
</span> </span>
</div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<button <button
disabled={(page + 1) * size >= list.length} disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage((prev) => prev + 1)} onClick={() => setPage((prev) => prev + 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed"> className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronRight /> <BsChevronRight />
</button> </button>
<button <button
disabled={(page + 1) * size >= list.length} disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage(Math.floor(list.length / size))} onClick={() => setPage(Math.floor(list.length / itemsPerPage))}
className="disabled:opacity-60 disabled:cursor-not-allowed"> className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronDoubleRight /> <BsChevronDoubleRight />
</button> </button>
</div> </div>
</div> </div>
); );
return {page, items, setPage, render, renderMinimal}; return { page, items, setPage, render, renderMinimal };
} }

View File

@@ -1,4 +1,3 @@
import instructions from "@/pages/api/exam/media/instructions";
import { Module } from "."; import { Module } from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; 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 // Left easy, medium and hard to support older exam versions
export type BasicDifficulty = "easy" | "medium" | "hard"; export type BasicDifficulty = "easy" | "medium" | "hard";
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2"; 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 { export interface ExamBase {
@@ -24,8 +26,10 @@ export interface ExamBase {
shuffle?: boolean; shuffle?: boolean;
createdBy?: string; // option as it has been added later createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later createdAt?: string; // option as it has been added later
private?: boolean; access: AccessType;
label?: string; label?: string;
requiresApproval?: boolean;
approved?: boolean;
} }
export interface ReadingExam extends ExamBase { export interface ReadingExam extends ExamBase {
module: "reading"; module: "reading";
@@ -238,6 +242,7 @@ export interface InteractiveSpeakingExercise extends Section {
} }
export interface FillBlanksMCOption { export interface FillBlanksMCOption {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; id: string;
options: { options: {
A: string; A: string;
@@ -255,6 +260,7 @@ export interface FillBlanksExercise {
text: string; // *EXAMPLE: "They tried to {{1}} burning" text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition?: boolean; allowRepetition?: boolean;
solutions: { solutions: {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve" solution: string; // *EXAMPLE: "preserve"
}[]; }[];
@@ -278,6 +284,7 @@ export interface TrueFalseExercise {
} }
export interface TrueFalseQuestion { export interface TrueFalseQuestion {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?" prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True" solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True"
@@ -290,6 +297,7 @@ export interface WriteBlanksExercise {
id: string; id: string;
text: string; // *EXAMPLE: "The Government plans to give ${{14}}" text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
solutions: { solutions: {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "14" id: string; // *EXAMPLE: "14"
solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive) solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive)
}[]; }[];
@@ -316,12 +324,14 @@ export interface MatchSentencesExercise {
} }
export interface MatchSentenceExerciseSentence { export interface MatchSentenceExerciseSentence {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; id: string;
sentence: string; sentence: string;
solution: string; solution: string;
} }
export interface MatchSentenceExerciseOption { export interface MatchSentenceExerciseOption {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; id: string;
sentence: string; sentence: string;
} }
@@ -343,6 +353,7 @@ export interface MultipleChoiceExercise {
export interface MultipleChoiceQuestion { export interface MultipleChoiceQuestion {
variant: "image" | "text"; variant: "image" | "text";
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?" prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: string; // *EXAMPLE: "A" solution: string; // *EXAMPLE: "A"

View File

@@ -1,7 +1,10 @@
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be"; 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( const results = await Promise.all(
examEntities.map(async (entity) => { examEntities.map(async (entity) => {
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor); 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 successCount = results.filter((r) => r.created).length;
const totalCount = examEntities.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 { return {
successCount, successCount,
totalCount, totalCount,

View File

@@ -1,7 +1,5 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import { PERMISSIONS } from "@/constants/userPermissions";
import useUsers from "@/hooks/useUsers";
import { Type, User } from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
@@ -15,19 +13,21 @@ import ShortUniqueId from "short-unique-id";
import { useFilePicker } from "use-file-picker"; import { useFilePicker } from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions"; import { PermissionType } from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select"; 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 { FaFileDownload } from "react-icons/fa";
import { IoInformationCircleOutline } from "react-icons/io5"; import { IoInformationCircleOutline } from "react-icons/io5";
import { HiOutlineDocumentText } from "react-icons/hi"; import { HiOutlineDocumentText } from "react-icons/hi";
import CodegenTable from "@/components/Tables/CodeGenTable"; 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: { const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] }; [key in Type]: { perm: PermissionType | undefined; list: Type[] };
@@ -54,11 +54,26 @@ const USER_TYPE_PERMISSIONS: {
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
}, },
developer: { developer: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
}, },
}; };
@@ -66,22 +81,38 @@ interface Props {
user: User; user: User;
users: User[]; users: User[];
permissions: PermissionType[]; permissions: PermissionType[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
onFinish: () => void; onFinish: () => void;
} }
export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) { export default function BatchCodeGenerator({
const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]); user,
users,
entities = [],
permissions,
onFinish,
}: Props) {
const [infos, setInfos] = useState<
{ email: string; name: string; passport_id: string }[]
>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).toDate()
: null
); );
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined); const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined }); const [parsedExcel, setParsedExcel] = useState<{
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>(); rows?: any[];
errors?: any[];
}>({ rows: undefined, errors: undefined });
const [duplicatedRows, setDuplicatedRows] = useState<{
duplicates: ExcelCodegenDuplicatesMap;
count: number;
}>();
const { openFilePicker, filesContent, clear } = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
@@ -94,62 +125,62 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const schema = { const schema = {
'First Name': { "First Name": {
prop: 'firstName', prop: "firstName",
type: String, type: String,
required: true, required: true,
validate: (value: string) => { validate: (value: string) => {
if (!value || value.trim() === '') { if (!value || value.trim() === "") {
throw new Error('First Name cannot be empty') throw new Error("First Name cannot be empty");
}
return true
} }
return true;
}, },
'Last Name': { },
prop: 'lastName', "Last Name": {
prop: "lastName",
type: String, type: String,
required: true, required: true,
validate: (value: string) => { validate: (value: string) => {
if (!value || value.trim() === '') { if (!value || value.trim() === "") {
throw new Error('Last Name cannot be empty') throw new Error("Last Name cannot be empty");
}
return true
} }
return true;
}, },
'Passport/National ID': { },
prop: 'passport_id', "Passport/National ID": {
prop: "passport_id",
type: String, type: String,
required: true, required: true,
validate: (value: string) => { validate: (value: string) => {
if (!value || value.trim() === '') { if (!value || value.trim() === "") {
throw new Error('Passport/National ID cannot be empty') throw new Error("Passport/National ID cannot be empty");
}
return true
} }
return true;
}, },
'E-mail': { },
prop: 'email', "E-mail": {
prop: "email",
required: true, required: true,
type: (value: any) => { type: (value: any) => {
if (!value || value.trim() === '') { if (!value || value.trim() === "") {
throw new Error('Email cannot be empty') throw new Error("Email cannot be empty");
} }
if (!EMAIL_REGEX.test(value.trim())) { if (!EMAIL_REGEX.test(value.trim())) {
throw new Error('Invalid Email') throw new Error("Invalid Email");
}
return value
}
}
} }
return value;
},
},
};
useEffect(() => { useEffect(() => {
if (filesContent.length > 0) { if (filesContent.length > 0) {
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile( readXlsxFile(file.content, { schema, ignoreEmptyRows: false }).then(
file.content, { schema, ignoreEmptyRows: false }) (data) => {
.then((data) => { setParsedExcel(data);
setParsedExcel(data) }
}); );
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
@@ -164,12 +195,14 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
const duplicateRowIndices = new Set<number>(); const duplicateRowIndices = new Set<number>();
const errorRowIndices = new Set( const errorRowIndices = new Set(
parsedExcel.errors?.map(error => error.row) || [] parsedExcel.errors?.map((error) => error.row) || []
); );
parsedExcel.rows.forEach((row, index) => { parsedExcel.rows.forEach((row, index) => {
if (!errorRowIndices.has(index + 2)) { if (!errorRowIndices.has(index + 2)) {
(Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).forEach(field => { (
Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
).forEach((field) => {
if (row !== null) { if (row !== null) {
const value = row[field]; const value = row[field];
if (value) { if (value) {
@@ -180,7 +213,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
if (existingRows) { if (existingRows) {
existingRows.push(index + 2); existingRows.push(index + 2);
duplicateValues.add(value); duplicateValues.add(value);
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum)); existingRows.forEach((rowNum) =>
duplicateRowIndices.add(rowNum)
);
} }
} }
} }
@@ -191,10 +226,23 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
const info = parsedExcel.rows const info = parsedExcel.rows
.map((row, index) => { .map((row, index) => {
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) { if (
errorRowIndices.has(index + 2) ||
duplicateRowIndices.has(index + 2) ||
row === null
) {
return undefined; return undefined;
} }
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row; const {
firstName,
lastName,
studentID,
passport_id,
email,
phone,
group,
country,
} = row;
if (!email || !EMAIL_REGEX.test(email.toString().trim())) { if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
return undefined; return undefined;
} }
@@ -204,31 +252,49 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined, passport_id: passport_id?.toString().trim() || undefined,
}; };
}).filter((x) => !!x) as typeof infos; })
.filter((x) => !!x) as typeof infos;
setInfos(info); setInfos(info);
} }
}, [entity, parsedExcel, type]); }, [entity, parsedExcel, type]);
const generateAndInvite = async () => { const generateAndInvite = async () => {
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email)); const newUsers = infos.filter(
(x) => !users.map((u) => u.email).includes(x.email)
);
const existingUsers = infos const existingUsers = infos
.filter((x) => users.map((u) => u.email).includes(x.email)) .filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email)) .map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[]; .filter((x) => !!x && x.type === "student") as User[];
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined; const newUsersSentence =
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined; newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
const existingUsersSentence =
existingUsers.length > 0
? `invite ${existingUsers.length} registered student(s)`
: undefined;
if ( if (
!confirm( !confirm(
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`, `You are about to ${[newUsersSentence, existingUsersSentence]
.filter((x) => !!x)
.join(" and ")}, are you sure you want to continue?`
) )
) )
return; return;
setIsLoading(true); setIsLoading(true);
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id }))) Promise.all(
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`)) 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(() => { .finally(() => {
if (newUsers.length === 0) setIsLoading(false); if (newUsers.length === 0) setIsLoading(false);
}); });
@@ -246,17 +312,20 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", { .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
type, type,
codes, codes,
infos: informations.map((info, index) => ({ ...info, code: codes[index] })), infos: informations.map((info, index) => ({
...info,
code: codes[index],
})),
expiryDate, expiryDate,
entity entity,
}) })
.then(({ data, status }) => { .then(({ data, status }) => {
if (data.ok) { if (data.ok) {
toast.success( toast.success(
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize( `Successfully generated${
type, data.valid ? ` ${data.valid}/${informations.length}` : ""
)} codes and they have been notified by e-mail!`, } ${capitalize(type)} codes and they have been notified by e-mail!`,
{ toastId: "success" }, { toastId: "success" }
); );
onFinish(); onFinish();
@@ -287,7 +356,7 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
const fileName = "BatchCodeTemplate.xlsx"; 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 url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = fileName; link.download = fileName;
@@ -301,11 +370,15 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
<> <>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}> <Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
<> <>
<div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div> <div className="flex font-bold text-xl justify-center text-gray-700">
<span>Excel File Format</span>
</div>
<div className="mt-4 flex flex-col gap-4"> <div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4"> <div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} /> <HiOutlineDocumentText
className={`w-5 h-5 text-mti-purple-light`}
/>
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
The uploaded document must: The uploaded document must:
</h2> </h2>
@@ -315,15 +388,24 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
be an Excel .xlsx document. be an Excel .xlsx document.
</li> </li>
<li className="text-gray-700 list-disc"> <li className="text-gray-700 list-disc">
only have a single spreadsheet with the following <b>exact same name</b> columns: only have a single spreadsheet with the following{" "}
<b>exact same name</b> columns:
<div className="py-4 pr-4"> <div className="py-4 pr-4">
<table className="w-full bg-white"> <table className="w-full bg-white">
<thead> <thead>
<tr> <tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th> <th className="border border-neutral-200 px-2 py-1">
<th className="border border-neutral-200 px-2 py-1">Last Name</th> First Name
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> </th>
<th className="border border-neutral-200 px-2 py-1">E-mail</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> </tr>
</thead> </thead>
</table> </table>
@@ -333,10 +415,10 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
</div> </div>
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4"> <div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} /> <IoInformationCircleOutline
<h2 className="text-lg font-semibold"> className={`w-5 h-5 text-mti-purple-light`}
Note that: />
</h2> <h2 className="text-lg font-semibold">Note that:</h2>
</div> </div>
<ul className="flex flex-col pl-10 gap-2"> <ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc"> <li className="text-gray-700 list-disc">
@@ -346,10 +428,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
all already registered e-mails will be ignored. all already registered e-mails will be ignored.
</li> </li>
<li className="text-gray-700 list-disc"> <li className="text-gray-700 list-disc">
all rows which contain duplicate values in the columns: &quot;Passport/National ID&quot;, &quot;E-mail&quot;, will be ignored. all rows which contain duplicate values in the columns:
&quot;Passport/National ID&quot;, &quot;E-mail&quot;, will be
ignored.
</li> </li>
<li className="text-gray-700 list-disc"> <li className="text-gray-700 list-disc">
all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below. all of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li> </li>
</ul> </ul>
</div> </div>
@@ -359,11 +444,21 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
</p> </p>
</div> </div>
<div className="w-full flex justify-between mt-6 gap-8"> <div className="w-full flex justify-between mt-6 gap-8">
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white"> <Button
color="purple"
onClick={() => setShowHelp(false)}
variant="outline"
className="self-end w-full bg-white"
>
Close Close
</Button> </Button>
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full"> <Button
color="purple"
onClick={handleTemplateDownload}
variant="solid"
className="self-end w-full"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaFileDownload size={24} /> <FaFileDownload size={24} />
Download Template Download Template
@@ -375,7 +470,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
</Modal> </Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label> <label className="text-mti-gray-dim text-base font-normal">
Choose an Excel file
</label>
<button <button
onClick={() => setShowHelp(true)} onClick={() => setShowHelp(true)}
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200" className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
@@ -384,14 +481,30 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
<IoInformationCircleOutline size={24} /> <IoInformationCircleOutline size={24} />
</button> </button>
</div> </div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> <Button
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button> </Button>
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( {user &&
checkAccess(user, [
"developer",
"admin",
"corporate",
"mastercorporate",
]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> Expiry Date
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user.subscriptionExpirationDate}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -400,11 +513,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
className={clsx( className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", "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", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
filterDate={(date) => filterDate={(date) =>
moment(date).isAfter(new Date()) && moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) (user.subscriptionExpirationDate
? moment(date).isBefore(user.subscriptionExpirationDate)
: true)
} }
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
@@ -414,41 +529,67 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
</> </>
)} )}
<div className={clsx("flex flex-col gap-4")}> <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 <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 }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)} onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])} isClearable={checkAccess(user, ["admin", "developer"])}
/> />
</div> </div>
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label> <label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && ( {user && (
<select <select
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"> className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
{Object.keys(USER_TYPE_LABELS) >
.filter((x) => { {Object.keys(USER_TYPE_LABELS).reduce((acc, x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm); if (checkAccess(user, getTypesOfUser(list), permissions, perm))
}) acc.push(
.map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option> </option>
))} );
return acc;
}, [] as JSX.Element[])}
</select> </select>
)} )}
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>} {infos.length > 0 && (
<CodeGenImportSummary
infos={infos}
parsedExcel={parsedExcel}
duplicateRows={duplicatedRows}
/>
)}
{infos.length !== 0 && ( {infos.length !== 0 && (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span> <span className="text-mti-gray-dim text-base font-normal">
Codes will be sent to:
</span>
<CodegenTable infos={infos} /> <CodegenTable infos={infos} />
</div> </div>
)} )}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && ( {checkAccess(
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> user,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"createCodes"
) && (
<Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send Generate & Send
</Button> </Button>
)} )}

View File

@@ -1,6 +1,5 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import { PERMISSIONS } from "@/constants/userPermissions";
import { Type, User } from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
@@ -13,10 +12,8 @@ import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions"; import { PermissionType } from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
const USER_TYPE_PERMISSIONS: { const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] }; [key in Type]: { perm: PermissionType | undefined; list: Type[] };
@@ -43,30 +40,52 @@ const USER_TYPE_PERMISSIONS: {
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
}, },
developer: { developer: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
}, },
}; };
interface Props { interface Props {
user: User; user: User;
permissions: PermissionType[]; permissions: PermissionType[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
onFinish: () => void; onFinish: () => void;
} }
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) { export default function CodeGenerator({
user,
entities = [],
permissions,
onFinish,
}: Props) {
const [generatedCode, setGeneratedCode] = useState<string>(); const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).toDate()
: null
); );
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
@@ -105,11 +124,18 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
return ( return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label> <label className="font-normal text-base text-mti-gray-dim">
User Code Generator
</label>
<div className={clsx("flex flex-col gap-4")}> <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 <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 }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)} onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])} isClearable={checkAccess(user, ["admin", "developer"])}
@@ -121,25 +147,33 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
<select <select
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
{Object.keys(USER_TYPE_LABELS) >
.filter((x) => { {Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm); if (checkAccess(user, getTypesOfUser(list), permissions, perm))
}) acc.push(x);
.map((type) => ( return acc;
<option key={type} value={type}> }, [])}
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select> </select>
</div> </div>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( {checkAccess(user, [
"developer",
"admin",
"corporate",
"mastercorporate",
]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> Expiry Date
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user.subscriptionExpirationDate}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -148,11 +182,13 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
className={clsx( className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", "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", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
filterDate={(date) => filterDate={(date) =>
moment(date).isAfter(new Date()) && moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) (user.subscriptionExpirationDate
? moment(date).isBefore(user.subscriptionExpirationDate)
: true)
} }
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
@@ -161,25 +197,40 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
)} )}
</> </>
)} )}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && ( {checkAccess(
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}> user,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"createCodes"
) && (
<Button
onClick={() => generateCode(type)}
disabled={isExpiryDateEnabled ? !expiryDate : false}
>
Generate Generate
</Button> </Button>
)} )}
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label> <label className="font-normal text-base text-mti-gray-dim">
Generated Code:
</label>
<div <div
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "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", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
data-tip="Click to copy" data-tip="Click to copy"
onClick={() => { onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode); if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}> }}
>
{generatedCode} {generatedCode}
</div> </div>
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>} {generatedCode && (
<span className="text-sm text-mti-gray-dim font-light">
Give this code to the user to complete their registration
</span>
)}
</div> </div>
); );
} }

View File

@@ -265,7 +265,7 @@ export default function CorporateGradingSystem({
<> <>
<Separator /> <Separator />
<label className="font-normal text-base text-mti-gray-dim"> <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> </label>
<Select <Select
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
@@ -282,7 +282,7 @@ export default function CorporateGradingSystem({
disabled={isLoading || otherEntities.length === 0} disabled={isLoading || otherEntities.length === 0}
variant="outline" variant="outline"
> >
Apply to {otherEntities.length} other entities Copy to {otherEntities.length} other entities
</Button> </Button>
<Separator /> <Separator />
</> </>
@@ -326,7 +326,7 @@ export default function CorporateGradingSystem({
disabled={isLoading} disabled={isLoading}
className="mt-8" className="mt-8"
> >
Save Grading System Save Changes to entities
</Button> </Button>
</div> </div>
); );

View File

@@ -1,6 +1,5 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import axios from "axios"; import axios from "axios";
import { capitalize, uniqBy } from "lodash";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useFilePicker } from "use-file-picker"; import { useFilePicker } from "use-file-picker";

View File

@@ -15,26 +15,41 @@ import { findBy, mapBy } from "@/utils";
import useEntitiesCodes from "@/hooks/useEntitiesCodes"; import useEntitiesCodes from "@/hooks/useEntitiesCodes";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
type TableData = Code & { entity?: EntityWithRoles, creator?: User } type TableData = Code & { entity?: EntityWithRoles; creator?: User };
const columnHelper = createColumnHelper<TableData>(); const columnHelper = createColumnHelper<TableData>();
export default function CodeList({ user, entities, canDeleteCodes } export default function CodeList({
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) { user,
entities,
canDeleteCodes,
}: {
user: User;
entities: EntityWithRoles[];
canDeleteCodes?: boolean;
}) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]); const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities]) const entityIDs = useMemo(() => mapBy(entities, "id"), [entities]);
const { users } = useUsers(); const { users } = useUsers();
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs) const { codes, reload, isLoading } = useEntitiesCodes(
isAdmin(user) ? undefined : entityIDs
);
const data: TableData[] = useMemo(() => codes.map((code) => ({ const data: TableData[] = useMemo(
() =>
codes.map((code) => ({
...code, ...code,
entity: findBy(entities, 'id', code.entity), entity: findBy(entities, "id", code.entity),
creator: findBy(users, 'id', code.creator) creator: findBy(users, "id", code.creator),
})) as TableData[], [codes, entities, users]) })) as TableData[],
[codes, entities, users]
);
const toggleCode = (id: string) => { const toggleCode = (id: string) => {
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); setSelectedCodes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
}; };
// const toggleAllCodes = (checked: boolean) => { // const toggleAllCodes = (checked: boolean) => {
@@ -44,8 +59,11 @@ export default function CodeList({ user, entities, canDeleteCodes }
// }; // };
const deleteCodes = async (codes: string[]) => { const deleteCodes = async (codes: string[]) => {
if (!canDeleteCodes) return if (!canDeleteCodes) return;
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return; if (
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
)
return;
const params = new URLSearchParams(); const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code)); codes.forEach((code) => params.append("code", code));
@@ -73,8 +91,9 @@ export default function CodeList({ user, entities, canDeleteCodes }
}; };
const deleteCode = async (code: Code) => { const deleteCode = async (code: Code) => {
if (!canDeleteCodes) return if (!canDeleteCodes) return;
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return; if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
return;
axios axios
.delete(`/api/code/${code.code}`) .delete(`/api/code/${code.code}`)
@@ -99,10 +118,13 @@ export default function CodeList({ user, entities, canDeleteCodes }
columnHelper.accessor("code", { columnHelper.accessor("code", {
id: "codeCheckbox", id: "codeCheckbox",
enableSorting: false, enableSorting: false,
header: () => (""), header: () => "",
cell: (info) => cell: (info) =>
!info.row.original.userId ? ( !info.row.original.userId ? (
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}> <Checkbox
isChecked={selectedCodes.includes(info.getValue())}
onChange={() => toggleCode(info.getValue())}
>
{""} {""}
</Checkbox> </Checkbox>
) : null, ) : null,
@@ -113,7 +135,8 @@ export default function CodeList({ user, entities, canDeleteCodes }
}), }),
columnHelper.accessor("creationDate", { columnHelper.accessor("creationDate", {
header: "Creation Date", header: "Creation Date",
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"), cell: (info) =>
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: "E-mail", header: "E-mail",
@@ -121,7 +144,12 @@ export default function CodeList({ user, entities, canDeleteCodes }
}), }),
columnHelper.accessor("creator", { columnHelper.accessor("creator", {
header: "Creator", header: "Creator",
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A", cell: (info) =>
info.getValue()
? `${info.getValue().name} (${
USER_TYPE_LABELS[info.getValue().type]
})`
: "N/A",
}), }),
columnHelper.accessor("entity", { columnHelper.accessor("entity", {
header: "Entity", header: "Entity",
@@ -147,7 +175,11 @@ export default function CodeList({ user, entities, canDeleteCodes }
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{canDeleteCodes && !row.original.userId && ( {canDeleteCodes && !row.original.userId && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}> <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" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -168,7 +200,8 @@ export default function CodeList({ user, entities, canDeleteCodes }
variant="outline" variant="outline"
color="red" color="red"
className="!py-1 px-10" className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}> onClick={() => deleteCodes(selectedCodes)}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -177,7 +210,14 @@ export default function CodeList({ user, entities, canDeleteCodes }
<Table<TableData> <Table<TableData>
data={data} data={data}
columns={defaultColumns} columns={defaultColumns}
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]} isLoading={isLoading}
searchFields={[
["code"],
["email"],
["entity", "label"],
["creator", "name"],
["creator", "type"],
]}
/> />
</> </>
); );

View File

@@ -1,54 +1,79 @@
import {useMemo, useState} from "react"; import { useMemo, useState } from "react";
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import useExams from "@/hooks/useExams"; import useExams from "@/hooks/useExams";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Exam} from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import {getExamById} from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import {capitalize, uniq} from "lodash"; import { capitalize } from "lodash";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsBan, BsCheck, BsCircle, BsPencil, BsTrash, BsUpload, BsX} from "react-icons/bs"; import { BsPencil, BsTrash, BsUpload } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {useListSearch} from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {checkAccess} from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import useGroups from "@/hooks/useGroups";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {EntityWithRoles} from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import {BiEdit} from "react-icons/bi"; import { BiEdit } from "react-icons/bi";
import {findBy, mapBy} from "@/utils"; import { findBy, mapBy } from "@/utils";
import {getUserName} from "@/utils/users";
const searchFields = [["module"], ["id"], ["createdBy"]]; const searchFields = [["module"], ["id"], ["createdBy"]];
const CLASSES: {[key in Module]: string} = { const CLASSES: { [key in Module]: string } = {
reading: "text-ielts-reading", reading: "text-ielts-reading",
listening: "text-ielts-listening", listening: "text-ielts-listening",
speaking: "text-ielts-speaking", speaking: "text-ielts-speaking",
writing: "text-ielts-writing", writing: "text-ielts-writing",
level: "text-ielts-level", level: "text-ielts-level",
}; };
const columnHelper = createColumnHelper<Exam>(); const columnHelper = createColumnHelper<Exam>();
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) { export default function ExamList({
user,
entities,
}: {
user: User;
entities: EntityWithRoles[];
}) {
const [selectedExam, setSelectedExam] = useState<Exam>(); const [selectedExam, setSelectedExam] = useState<Exam>();
const {exams, reload} = useExams(); const canViewConfidentialEntities = useMemo(
const {users} = useUsers(); () =>
mapBy(
findAllowedEntities(user, entities, "view_confidential_exams"),
"id"
),
[user, entities]
);
const { exams, reload, isLoading } = useExams();
const { users } = useUsers();
// Pass this permission filter to the backend later
const filteredExams = useMemo( const filteredExams = useMemo(
() => () =>
exams.filter((e) => { ["admin", "developer"].includes(user?.type)
if (!e.private) return true; ? exams
return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent)); : exams.filter((item) => {
if (
item.access === "confidential" &&
!canViewConfidentialEntities.find((x) =>
(item.entities ?? []).includes(x)
)
)
return false;
return true;
}), }),
[exams, user?.entities], [canViewConfidentialEntities, exams, user?.type]
); );
const parsedExams = useMemo(() => { const parsedExams = useMemo(() => {
@@ -67,7 +92,10 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}); });
}, [filteredExams, users]); }, [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);
@@ -76,22 +104,36 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
const loadExam = async (module: Module, examId: string) => { const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim()); const exam = await getExamById(module, examId.trim());
if (!exam) { if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", { toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{
toastId: "invalid-exam-id", toastId: "invalid-exam-id",
}); }
);
return; return;
} }
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}}); dispatch({
type: "INIT_EXAM",
payload: { exams: [exam], modules: [module] },
});
router.push("/exam"); router.push("/exam");
}; };
/*
const privatizeExam = async (exam: 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; if (
!confirm(
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
exam.access
}?`
)
)
return;
axios axios
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private}) .patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
.then(() => toast.success(`Updated the "${exam.id}" exam`)) .then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
@@ -108,9 +150,15 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}) })
.finally(reload); .finally(reload);
}; };
*/
const deleteExam = async (exam: Exam) => { const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return; if (
!confirm(
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
)
)
return;
axios axios
.delete(`/api/exam/${exam.module}/${exam.id}`) .delete(`/api/exam/${exam.module}/${exam.id}`)
@@ -132,8 +180,12 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}; };
const getTotalExercises = (exam: Exam) => { const getTotalExercises = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") { if (
return countExercises(exam.parts.flatMap((x) => x.exercises)); 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);
@@ -146,7 +198,11 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}), }),
columnHelper.accessor("module", { columnHelper.accessor("module", {
header: "Module", header: "Module",
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>, cell: (info) => (
<span className={CLASSES[info.getValue()]}>
{capitalize(info.getValue())}
</span>
),
}), }),
columnHelper.accessor((x) => getTotalExercises(x), { columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises", header: "Exercises",
@@ -156,9 +212,9 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
header: "Timer", header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>, cell: (info) => <>{info.getValue()} minute(s)</>,
}), }),
columnHelper.accessor("private", { columnHelper.accessor("access", {
header: "Private", header: "Access",
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>, cell: (info) => <span>{capitalize(info.getValue())}</span>,
}), }),
columnHelper.accessor("createdAt", { columnHelper.accessor("createdAt", {
header: "Created At", header: "Created At",
@@ -173,24 +229,30 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}), }),
columnHelper.accessor("createdBy", { columnHelper.accessor("createdBy", {
header: "Created By", header: "Created By",
cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"), cell: (info) =>
!info.getValue()
? "System"
: findBy(users, "id", info.getValue())?.name || "N/A",
}), }),
{ {
header: "", header: "",
id: "actions", id: "actions",
cell: ({row}: {row: {original: Exam}}) => { cell: ({ row }: { row: { original: Exam } }) => {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && ( {(row.original.owners?.includes(user.id) ||
checkAccess(user, ["admin", "developer"])) && (
<> <>
{checkAccess(user, [
"admin",
"developer",
"mastercorporate",
]) && (
<button <button
data-tip={row.original.private ? "Set as public" : "Set as private"} data-tip="Edit exam"
onClick={async () => await privatizeExam(row.original)} onClick={() => setSelectedExam(row.original)}
className="cursor-pointer tooltip"> 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 /> <BsPencil />
</button> </button>
)} )}
@@ -199,11 +261,18 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
<button <button
data-tip="Load exam" data-tip="Load exam"
className="cursor-pointer tooltip" className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}> onClick={async () =>
await loadExam(row.original.module, row.original.id)
}
>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button> </button>
{PERMISSIONS.examManagement.delete.includes(user.type) && ( {PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}> <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" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -220,34 +289,53 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}); });
const handleExamEdit = () => { const handleExamEdit = () => {
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`); router.push(
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
);
}; };
return ( return (
<div className="flex flex-col gap-4 w-full h-full"> <div className="flex flex-col gap-4 w-full h-full">
{renderSearch()} {renderSearch()}
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl"> <Modal
isOpen={!!selectedExam}
onClose={() => setSelectedExam(undefined)}
maxWidth="max-w-xl"
>
{!!selectedExam ? ( {!!selectedExam ? (
<> <>
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<BiEdit className="w-5 h-5 text-gray-600" /> <BiEdit className="w-5 h-5 text-gray-600" />
<span className="text-gray-600 font-medium">Ready to Edit</span> <span className="text-gray-600 font-medium">
Ready to Edit
</span>
</div> </div>
<div className="bg-gray-50 rounded-lg p-4 mb-3"> <div className="bg-gray-50 rounded-lg p-4 mb-3">
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p> <p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
</div> </div>
<p className="text-gray-500 text-sm">Click &apos;Next&apos; to proceed to the exam editor.</p> <p className="text-gray-500 text-sm">
Click &apos;Next&apos; to proceed to the exam editor.
</p>
</div> </div>
<div className="flex justify-between gap-4 mt-8"> <div className="flex justify-between gap-4 mt-8">
<Button color="purple" variant="outline" onClick={() => setSelectedExam(undefined)} className="w-32"> <Button
color="purple"
variant="outline"
onClick={() => setSelectedExam(undefined)}
className="w-32"
>
Cancel Cancel
</Button> </Button>
<Button color="purple" onClick={handleExamEdit} className="w-32 text-white flex items-center justify-center gap-2"> <Button
color="purple"
onClick={handleExamEdit}
className="w-32 text-white flex items-center justify-center gap-2"
>
Proceed Proceed
</Button> </Button>
</div> </div>
@@ -264,7 +352,12 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}> <th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -272,7 +365,10 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> <tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}> <td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -282,6 +378,17 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
))} ))}
</tbody> </tbody>
</table> </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> </div>
); );
} }

View File

@@ -1,31 +1,30 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useGroups from "@/hooks/useGroups"; import { Group, User } from "@/interfaces/user";
import useUsers from "@/hooks/useUsers"; import { createColumnHelper } from "@tanstack/react-table";
import { CorporateUser, Group, User } from "@/interfaces/user";
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import { capitalize, uniq } from "lodash"; import { uniq } from "lodash";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs"; import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
import Select from "react-select"; import Select from "react-select";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import { useFilePicker } from "use-file-picker"; import { useFilePicker } from "use-file-picker";
import { getUserCorporate } from "@/utils/groups"; import { USER_TYPE_LABELS } from "@/resources/user";
import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { useListSearch } from "@/hooks/useListSearch";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
import useEntitiesGroups from "@/hooks/useEntitiesGroups"; import useEntitiesGroups from "@/hooks/useEntitiesGroups";
import useEntitiesUsers from "@/hooks/useEntitiesUsers"; import useEntitiesUsers from "@/hooks/useEntitiesUsers";
import { WithEntity } from "@/interfaces/entity"; import { WithEntity } from "@/interfaces/entity";
const searchFields = [["name"]]; const searchFields = [["name"]];
const columnHelper = createColumnHelper<WithEntity<Group>>(); 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 { interface CreateDialogProps {
user: User; user: User;
@@ -35,9 +34,13 @@ interface CreateDialogProps {
} }
const CreatePanel = ({ user, users, group, onClose }: 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 [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 [isLoading, setIsLoading] = useState(false);
const { openFilePicker, filesContent, clear } = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
@@ -47,9 +50,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
}); });
const availableUsers = useMemo(() => { const availableUsers = useMemo(() => {
if (user?.type === "teacher") return users.filter((x) => ["student"].includes(x.type)); if (user?.type === "teacher")
if (user?.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type)); return users.filter((x) => ["student"].includes(x.type));
if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "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; return users;
}, [user, users]); }, [user, users]);
@@ -64,9 +72,12 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
rows rows
.map((row) => { .map((row) => {
const [email] = row as string[]; 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) { if (emails.length === 0) {
@@ -76,12 +87,17 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
return; 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( const filteredUsers = emailUsers.filter(
(x) => (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")) || (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)); setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
@@ -89,7 +105,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
user.type !== "teacher" user.type !== "teacher"
? "Added all teachers and students found in the file you've provided!" ? "Added all teachers and students found in the file you've provided!"
: "Added all 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); setIsLoading(false);
}); });
@@ -100,15 +116,27 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
const submit = () => { const submit = () => {
setIsLoading(true); setIsLoading(true);
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) { if (
toast.error("That group name is reserved and cannot be used, please enter another one."); 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); setIsLoading(false);
return; 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(() => { .then(() => {
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`); toast.success(
`Group "${name}" ${group ? "edited" : "created"} successfully`
);
return true; return true;
}) })
.catch(() => { .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 ( return (
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2"> <div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
<div className="flex flex-col gap-8"> <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 w-full flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-mti-gray-dim text-base font-normal">Participants</label> <label className="text-mti-gray-dim text-base font-normal">
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails."> Participants
</label>
<div
className="tooltip"
data-tip="The Excel file should only include a column with the desired e-mails."
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</div> </div>
</div> </div>
<div className="flex w-full gap-8"> <div className="flex w-full gap-8">
<Select <Select
className="w-full" className="w-full"
value={participants.map((x) => ({ value={value}
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
placeholder="Participants..." placeholder="Participants..."
defaultValue={participants.map((x) => ({ defaultValue={value}
value: x, options={userOptions}
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}` }))}
onChange={(value) => setParticipants(value.map((x) => x.value))} onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti isMulti
isSearchable isSearchable
@@ -160,18 +216,36 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
}} }}
/> />
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline"> <Button
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name} 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> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="mt-8 flex w-full items-center justify-end gap-8"> <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 Cancel
</Button> </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 Submit
</Button> </Button>
</div> </div>
@@ -182,7 +256,8 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
export default function GroupList({ user }: { user: User }) { export default function GroupList({ user }: { user: User }) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>(); const [editingGroup, setEditingGroup] = useState<Group>();
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>(); const [viewingAllParticipants, setViewingAllParticipants] =
useState<string>();
const { permissions } = usePermissions(user?.id || ""); const { permissions } = usePermissions(user?.id || "");
@@ -211,7 +286,14 @@ export default function GroupList({ user }: { user: User }) {
columnHelper.accessor("admin", { columnHelper.accessor("admin", {
header: "Admin", header: "Admin",
cell: (info) => ( 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} {users.find((x) => x.id === info.getValue())?.name}
</div> </div>
), ),
@@ -226,20 +308,27 @@ export default function GroupList({ user }: { user: User }) {
<span> <span>
{info {info
.getValue() .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) .map((x) => users.find((y) => y.id === x)?.name)
.join(", ")} .join(", ")}
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && ( {info.getValue().length > 5 &&
viewingAllParticipants !== info.row.original.id && (
<button <button
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300" className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
onClick={() => setViewingAllParticipants(info.row.original.id)}> onClick={() => setViewingAllParticipants(info.row.original.id)}
>
, View More , View More
</button> </button>
)} )}
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && ( {info.getValue().length > 5 &&
viewingAllParticipants === info.row.original.id && (
<button <button
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300" className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
onClick={() => setViewingAllParticipants(undefined)}> onClick={() => setViewingAllParticipants(undefined)}
>
, View Less , View Less
</button> </button>
)} )}
@@ -252,15 +341,29 @@ export default function GroupList({ user }: { user: User }) {
cell: ({ row }: { row: { original: Group } }) => { cell: ({ row }: { row: { original: Group } }) => {
return ( return (
<> <>
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && ( {user &&
(checkAccess(user, ["developer", "admin"]) ||
user.id === row.original.admin) && (
<div className="flex gap-2"> <div className="flex gap-2">
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && ( {(!row.original.disableEditing ||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}> 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" /> <BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div> </div>
)} )}
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && ( {(!row.original.disableEditing ||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}> 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" /> <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 ( return (
<div className="h-full w-full rounded-xl flex flex-col gap-4"> <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 <CreatePanel
group={editingGroup} group={editingGroup}
user={user} user={user}
@@ -288,12 +395,22 @@ export default function GroupList({ user }: { user: User }) {
users={users} users={users}
/> />
</Modal> </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 <button
onClick={() => setIsCreating(true)} 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 New Group
</button> </button>
)} )}

View File

@@ -1,20 +1,25 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import usePackages from "@/hooks/usePackages"; import usePackages from "@/hooks/usePackages";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Package} from "@/interfaces/paypal"; import { Package } from "@/interfaces/paypal";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import {useState} from "react"; import { useCallback, useMemo, useState } from "react";
import {BsPencil, BsTrash} from "react-icons/bs"; import { BsPencil, BsTrash } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import Select from "react-select"; import Select from "react-select";
import {CURRENCIES} from "@/resources/paypal"; import { CURRENCIES } from "@/resources/paypal";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
const CLASSES: {[key in Module]: string} = { const CLASSES: { [key in Module]: string } = {
reading: "text-ielts-reading", reading: "text-ielts-reading",
listening: "text-ielts-listening", listening: "text-ielts-listening",
speaking: "text-ielts-speaking", speaking: "text-ielts-speaking",
@@ -26,20 +31,36 @@ const columnHelper = createColumnHelper<Package>();
type DurationUnit = "days" | "weeks" | "months" | "years"; type DurationUnit = "days" | "weeks" | "months" | "years";
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) { const currencyOptions = CURRENCIES.map(({ label, currency }) => ({
value: currency,
label,
}));
function PackageCreator({
pack,
onClose,
}: {
pack?: Package;
onClose: () => void;
}) {
const [duration, setDuration] = useState(pack?.duration || 1); const [duration, setDuration] = useState(pack?.duration || 1);
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months"); const [unit, setUnit] = useState<DurationUnit>(
pack?.duration_unit || "months"
);
const [price, setPrice] = useState(pack?.price || 0); const [price, setPrice] = useState(pack?.price || 0);
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR"); const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
const submit = () => { const submit = useCallback(() => {
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", { (pack ? axios.patch : axios.post)(
pack ? `/api/packages/${pack.id}` : "/api/packages",
{
duration, duration,
duration_unit: unit, duration_unit: unit,
price, price,
currency, currency,
}) }
)
.then(() => { .then(() => {
toast.success("New payment has been created successfully!"); toast.success("New payment has been created successfully!");
onClose(); onClose();
@@ -47,24 +68,38 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
}); });
}, [duration, unit, price, currency, pack, onClose]);
const currencyDefaultValue = useMemo(() => {
return {
value: currency || "EUR",
label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
}; };
}, [currency]);
return ( return (
<div className="flex flex-col gap-8 py-8"> <div className="flex flex-col gap-8 py-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Price *</label> <label className="font-normal text-base text-mti-gray-dim">
Price *
</label>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} /> <Input
defaultValue={price}
name="price"
type="number"
onChange={(e) => setPrice(parseInt(e))}
/>
<Select <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" 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}))} options={currencyOptions}
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} defaultValue={currencyDefaultValue}
onChange={(value) => setCurrency(value?.value || "EUR")} onChange={(value) => setCurrency(value?.value || "EUR")}
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} value={currencyDefaultValue}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -76,7 +111,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
@@ -84,23 +123,32 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
</div> </div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Duration *</label> <label className="font-normal text-base text-mti-gray-dim">
Duration *
</label>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} /> <Input
defaultValue={duration}
name="duration"
type="number"
onChange={(e) => setDuration(parseInt(e))}
/>
<Select <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" 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={[ options={[
{value: "days", label: "Days"}, { value: "days", label: "Days" },
{value: "weeks", label: "Weeks"}, { value: "weeks", label: "Weeks" },
{value: "months", label: "Months"}, { value: "months", label: "Months" },
{value: "years", label: "Years"}, { value: "years", label: "Years" },
]} ]}
defaultValue={{value: "months", label: "Months"}} defaultValue={{ value: "months", label: "Months" }}
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")} onChange={(value) =>
value={{value: unit, label: capitalize(unit)}} setUnit((value?.value as DurationUnit) || "months")
}
value={{ value: unit, label: capitalize(unit) }}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -112,7 +160,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
@@ -120,10 +172,19 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
</div> </div>
</div> </div>
<div className="flex w-full justify-end items-center gap-8 mt-8"> <div className="flex w-full justify-end items-center gap-8 mt-8">
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}> <Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={onClose}
>
Cancel Cancel
</Button> </Button>
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}> <Button
className="w-full max-w-[200px]"
onClick={submit}
disabled={!duration || !price}
>
Submit Submit
</Button> </Button>
</div> </div>
@@ -131,13 +192,14 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
); );
} }
export default function PackageList({user}: {user: User}) { export default function PackageList({ user }: { user: User }) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingPackage, setEditingPackage] = useState<Package>(); const [editingPackage, setEditingPackage] = useState<Package>();
const {packages, reload} = usePackages(); const { packages, reload } = usePackages();
const deletePackage = async (pack: Package) => { const deletePackage = useCallback(
async (pack: Package) => {
if (!confirm(`Are you sure you want to delete this package?`)) return; if (!confirm(`Are you sure you want to delete this package?`)) return;
axios axios
@@ -157,9 +219,12 @@ export default function PackageList({user}: {user: User}) {
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
}) })
.finally(reload); .finally(reload);
}; },
[reload]
);
const defaultColumns = [ const defaultColumns = useMemo(
() => [
columnHelper.accessor("id", { columnHelper.accessor("id", {
header: "ID", header: "ID",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
@@ -183,16 +248,24 @@ export default function PackageList({user}: {user: User}) {
{ {
header: "", header: "",
id: "actions", id: "actions",
cell: ({row}: {row: {original: Package}}) => { cell: ({ row }: { row: { original: Package } }) => {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{["developer", "admin"].includes(user.type) && ( {["developer", "admin"].includes(user?.type) && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}> <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" /> <BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
{["developer", "admin"].includes(user.type) && ( {["developer", "admin"].includes(user?.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}> <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" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -200,7 +273,9 @@ export default function PackageList({user}: {user: User}) {
); );
}, },
}, },
]; ],
[deletePackage, user]
);
const table = useReactTable({ const table = useReactTable({
data: packages, data: packages,
@@ -208,18 +283,19 @@ export default function PackageList({user}: {user: User}) {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const closeModal = () => { const closeModal = useCallback(() => {
setIsCreating(false); setIsCreating(false);
setEditingPackage(undefined); setEditingPackage(undefined);
reload(); reload();
}; }, [reload]);
return ( return (
<div className="w-full h-full rounded-xl"> <div className="w-full h-full rounded-xl">
<Modal <Modal
isOpen={isCreating || !!editingPackage} isOpen={isCreating || !!editingPackage}
onClose={closeModal} onClose={closeModal}
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}> title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
>
<PackageCreator onClose={closeModal} pack={editingPackage} /> <PackageCreator onClose={closeModal} pack={editingPackage} />
</Modal> </Modal>
<table className="bg-mti-purple-ultralight/40 w-full"> <table className="bg-mti-purple-ultralight/40 w-full">
@@ -228,7 +304,12 @@ export default function PackageList({user}: {user: User}) {
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}> <th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -236,7 +317,10 @@ export default function PackageList({user}: {user: User}) {
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> <tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}> <td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -248,7 +332,8 @@ export default function PackageList({user}: {user: User}) {
</table> </table>
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"> className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
>
New Package New Package
</button> </button>
</div> </div>

View File

@@ -5,7 +5,6 @@ import {averageLevelCalculator} from "@/utils/score";
import {groupByExam} from "@/utils/stats"; import {groupByExam} from "@/utils/stats";
import {createColumnHelper} from "@tanstack/react-table"; import {createColumnHelper} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string}; type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string};

View File

@@ -5,7 +5,7 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
BsCheck, BsCheck,
BsCheckCircle, BsCheckCircle,
@@ -22,8 +22,6 @@ import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
import { exportListToExcel } from "@/utils/users"; import { exportListToExcel } from "@/utils/users";
import usePermissions from "@/hooks/usePermissions";
import useUserBalance from "@/hooks/useUserBalance";
import useEntitiesUsers from "@/hooks/useEntitiesUsers"; import useEntitiesUsers from "@/hooks/useEntitiesUsers";
import { WithLabeledEntities } from "@/interfaces/entity"; import { WithLabeledEntities } from "@/interfaces/entity";
import Table from "@/components/High/Table"; 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) if (entitiesDownloadUsers.length === 0)
return toast.error("You are not allowed to download the user list."); return toast.error("You are not allowed to download the user list.");
const allowedRows = rows.filter((r) => const allowedRows = rows;
mapBy(r.entities, "id").some((e) => const csv = await exportListToExcel(allowedRows);
mapBy(entitiesDownloadUsers, "id").includes(e)
)
);
const csv = exportListToExcel(allowedRows);
const element = document.createElement("a"); 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.href = URL.createObjectURL(file);
element.download = "users.csv"; element.download = "users.xlsx";
document.body.appendChild(element); document.body.appendChild(element);
element.click(); element.click();
document.body.removeChild(element); document.body.removeChild(element);

View File

@@ -4,7 +4,6 @@ import clsx from "clsx";
import CodeList from "./CodeList"; import CodeList from "./CodeList";
import DiscountList from "./DiscountList"; import DiscountList from "./DiscountList";
import ExamList from "./ExamList"; import ExamList from "./ExamList";
import GroupList from "./GroupList";
import PackageList from "./PackageList"; import PackageList from "./PackageList";
import UserList from "./UserList"; import UserList from "./UserList";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";

View File

@@ -1,24 +1,17 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import { PERMISSIONS } from "@/constants/userPermissions"; import { Type, User } from "@/interfaces/user";
import { CorporateUser, TeacherUser, Type, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions"; import { PermissionType } from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import CountrySelect from "@/components/Low/CountrySelect"; 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 Select from "@/components/Low/Select";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import useEntitiesGroups from "@/hooks/useEntitiesGroups"; import useEntitiesGroups from "@/hooks/useEntitiesGroups";
@@ -48,23 +41,44 @@ const USER_TYPE_PERMISSIONS: {
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
}, },
developer: { developer: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
}, },
}; };
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
permissions: PermissionType[]; permissions: PermissionType[];
onFinish: () => void; 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 [name, setName] = useState<string>();
const [email, setEmail] = useState<string>(); const [email, setEmail] = useState<string>();
const [phone, setPhone] = 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 [password, setPassword] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>(); const [confirmPassword, setConfirmPassword] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate
? moment(user?.subscriptionExpirationDate).toDate()
: null
); );
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [position, setPosition] = useState<string>(); const [position, setPosition] = useState<string>();
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const { groups } = useEntitiesGroups(); const { groups } = useEntitiesGroups();
@@ -90,11 +106,16 @@ export default function UserCreator({ user, users, entities = [], permissions, o
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const createUser = () => { const createUser = () => {
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!"); if (!name || name.trim().length === 0)
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!"); return toast.error("Please enter a valid name!");
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!"); if (!email || email.trim().length === 0)
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!"); return toast.error("Please enter a valid e-mail address!");
if (password !== confirmPassword) return toast.error("The passwords do not match!"); 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); setIsLoading(true);
@@ -128,8 +149,12 @@ export default function UserCreator({ user, users, entities = [], permissions, o
setStudentID(""); setStudentID("");
setCountry(user?.demographicInformation?.country); setCountry(user?.demographicInformation?.country);
setGroup(null); setGroup(null);
setEntity((entities || [])[0]?.id || undefined) setEntity((entities || [])[0]?.id || undefined);
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null); setExpiryDate(
user?.subscriptionExpirationDate
? moment(user?.subscriptionExpirationDate).toDate()
: null
);
setIsExpiryDateEnabled(true); setIsExpiryDateEnabled(true);
setType("student"); setType("student");
setPosition(undefined); setPosition(undefined);
@@ -145,10 +170,34 @@ export default function UserCreator({ user, users, entities = [], permissions, o
return ( return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" /> <Input
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" /> 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 <Input
type="password" type="password"
name="confirmPassword" name="confirmPassword"
@@ -160,11 +209,21 @@ export default function UserCreator({ user, users, entities = [], permissions, o
/> />
<div className="flex flex-col gap-4"> <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} /> <CountrySelect value={country} onChange={setCountry} />
</div> </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" && ( {type === "student" && (
<> <>
@@ -177,14 +236,26 @@ export default function UserCreator({ user, users, entities = [], permissions, o
placeholder="National ID or Passport number" placeholder="National ID or Passport number"
required 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")}> <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 <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 }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)} onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])} isClearable={checkAccess(user, ["admin", "developer"])}
@@ -192,11 +263,20 @@ export default function UserCreator({ user, users, entities = [], permissions, o
</div> </div>
{["corporate", "mastercorporate"].includes(type) && ( {["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")}> <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 <Select
options={groups options={groups
.filter((x) => x.entity?.id === entity) .filter((x) => x.entity?.id === entity)
@@ -209,38 +289,52 @@ export default function UserCreator({ user, users, entities = [], permissions, o
<div <div
className={clsx( className={clsx(
"flex flex-col gap-4", "flex flex-col gap-4",
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2", !checkAccess(user, [
)}> "developer",
<label className="font-normal text-base text-mti-gray-dim">Type</label> "admin",
"corporate",
"mastercorporate",
]) && "col-span-2"
)}
>
<label className="font-normal text-base text-mti-gray-dim">
Type
</label>
{user && ( {user && (
<select <select
defaultValue="student" defaultValue="student"
value={type} value={type}
onChange={(e) => setType(e.target.value as 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"> 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) => { {Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm); if (checkAccess(user, getTypesOfUser(list), permissions, perm))
}) acc.push(x);
.map((type) => ( return acc;
<option key={type} value={type}> }, [])}
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select> </select>
)} )}
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( {user &&
checkAccess(user, [
"developer",
"admin",
"corporate",
"mastercorporate",
]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">
Expiry Date
</label>
<Checkbox <Checkbox
isChecked={isExpiryDateEnabled} isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled} onChange={setIsExpiryDateEnabled}
disabled={!!user?.subscriptionExpirationDate}> disabled={!!user?.subscriptionExpirationDate}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -249,11 +343,15 @@ export default function UserCreator({ user, users, entities = [], permissions, o
className={clsx( className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", "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", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
filterDate={(date) => filterDate={(date) =>
moment(date).isAfter(new Date()) && moment(date).isAfter(new Date()) &&
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) (user?.subscriptionExpirationDate
? moment(date).isBefore(
user?.subscriptionExpirationDate
)
: true)
} }
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
@@ -265,7 +363,11 @@ export default function UserCreator({ user, users, entities = [], permissions, o
</div> </div>
</div> </div>
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}> <Button
onClick={createUser}
isLoading={isLoading}
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
>
Create User Create User
</Button> </Button>
</div> </div>

View File

@@ -140,10 +140,10 @@ export default function ExamPage({
useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id); useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
useEffect(() => { /* useEffect(() => {
setModuleLock(true); setModuleLock(true);
}, [flags.finalizeModule]); }, [flags.finalizeModule]);
*/
useEffect(() => { useEffect(() => {
if (flags.finalizeModule && !showSolutions) { if (flags.finalizeModule && !showSolutions) {
if ( if (
@@ -183,9 +183,9 @@ export default function ExamPage({
}) })
); );
const updatedSolutions = userSolutions.map((solution) => { const updatedSolutions = userSolutions.map((solution) => {
const completed = results const completed = results.find(
.filter((r) => r !== null) (c: any) => c && c.exercise === solution.exercise
.find((c: any) => c.exercise === solution.exercise); );
return completed || solution; return completed || solution;
}); });
setUserSolutions(updatedSolutions); setUserSolutions(updatedSolutions);

View File

@@ -43,11 +43,11 @@ export default function RegisterCorporate({
const [subscriptionDuration, setSubscriptionDuration] = useState(1); const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const { acceptedTerms, renderCheckbox } = useAcceptedTerms(); const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
const { users } = useUsers(); const { users } = useUsers({ type: "agent" });
const onSuccess = () => const onSuccess = () =>
toast.success( toast.success(
"An e-mail has been sent, please make sure to check your spam folder!", "An e-mail has been sent, please make sure to check your spam folder!"
); );
const onError = (e: Error) => { const onError = (e: Error) => {
@@ -83,7 +83,7 @@ export default function RegisterCorporate({
}) })
.then((response) => { .then((response) => {
mutateUser(response.data.user).then(() => mutateUser(response.data.user).then(() =>
sendEmailVerification(setIsLoading, onSuccess, onError), sendEmailVerification(setIsLoading, onSuccess, onError)
); );
}) })
.catch((error) => { .catch((error) => {
@@ -178,9 +178,10 @@ export default function RegisterCorporate({
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed" 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={[ options={[
{ value: "", label: "No referral" }, { value: "", label: "No referral" },
...users ...users.map((x) => ({
.filter((u) => u.type === "agent") value: x.id,
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })), label: `${x.name} - ${x.email}`,
})),
]} ]}
defaultValue={{ value: "", label: "No referral" }} defaultValue={{ value: "", label: "No referral" }}
onChange={(value) => setReferralAgent(value?.value)} onChange={(value) => setReferralAgent(value?.value)}
@@ -229,7 +230,7 @@ export default function RegisterCorporate({
? availableDurations[ ? availableDurations[
value.value as keyof typeof availableDurations value.value as keyof typeof availableDurations
].number ].number
: 1, : 1
) )
} }
styles={{ styles={{

View File

@@ -19,5 +19,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return res.status(403).json({ ok: false }); 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));
}
} }

View File

@@ -3,16 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Code, Group, Type } from "@/interfaces/user"; import { Code, } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { prepareMailer, prepareMailOptions } from "@/email";
import { isAdmin } from "@/utils/users";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { doesEntityAllow } from "@/utils/permissions";
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
import { findBy } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -30,7 +22,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const { entities } = req.query as { entities?: string[] }; const { entities } = req.query as { entities?: string[] };
if (entities) 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()); return res.status(200).json(await db.collection("codes").find<Code>({}).toArray());
} }

View File

@@ -32,6 +32,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const { entity } = req.query as { entity?: string }; const { entity } = req.query as { entity?: string };
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray(); const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
res.status(200).json(snapshot); res.status(200).json(snapshot);

View File

@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; import { Exam, ExamBase, InstructorGender, LevelExam, ListeningExam, ReadingExam, SpeakingExam, Variant } from "@/interfaces/exam";
import { createApprovalWorkflowsOnExamCreation } from "@/lib/createWorkflowsOnExamCreation"; import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
@@ -10,6 +10,8 @@ import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/a
import { generateExamDifferences } from "@/utils/exam.differences"; import { generateExamDifferences } from "@/utils/exam.differences";
import { getExams } from "@/utils/exams.be"; import { getExams } from "@/utils/exams.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { uuidv4 } from "@firebase/util";
import { access } from "fs";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@@ -17,6 +19,24 @@ const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
// Temporary: Adding UUID here but later move to backend.
function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
exam.parts = exam.parts.map((part) => {
const updatedExercises = part.exercises.map((exercise: any) => {
arraysToUpdate.forEach((arrayName) => {
if (exercise[arrayName] && Array.isArray(exercise[arrayName])) {
exercise[arrayName] = exercise[arrayName].map((item: any) => (item.uuid ? item : { ...item, uuid: uuidv4() }));
}
});
return exercise;
});
return { ...part, exercises: updatedExercises };
});
return exam;
}
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await GET(req, res); if (req.method === "GET") return await GET(req, res);
if (req.method === "POST") return await POST(req, res); if (req.method === "POST") return await POST(req, res);
@@ -48,10 +68,11 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
const { module } = req.query as { module: string }; const { module } = req.query as { module: string };
const session = client.startSession(); 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 entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
try { try {
const exam = { let exam = {
access: "public", // default access is public
...req.body, ...req.body,
module: module, module: module,
entities, entities,
@@ -59,6 +80,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
// Temporary: Adding UUID here but later move to backend.
exam = addUUIDs(exam);
let responseStatus: number; let responseStatus: number;
let responseMessage: string; let responseMessage: string;
@@ -76,6 +100,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
throw new Error("Name already exists"); throw new Error("Name already exists");
} }
if (exam.requiresApproval === true) {
exam.access = "confidential";
}
await db.collection(module).updateOne( await db.collection(module).updateOne(
{ id: req.body.id }, { id: req.body.id },
{ $set: { id: req.body.id, ...exam } }, { $set: { id: req.body.id, ...exam } },
@@ -88,36 +116,44 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response. // 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; responseStatus = 200;
responseMessage = `Successfully updated exam with ID: "${exam.id}"`; 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.
// create workflow only if exam is being created for the first time // create workflow only if exam is being created for the first time
if (docSnap === null) { if (docSnap === null) {
try { try {
const { successCount, totalCount } = await createApprovalWorkflowsOnExamCreation(exam.createdBy, exam.entities, exam.id, module); 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 (successCount === totalCount) { if (successCount === totalCount) {
responseStatus = 200; responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s)`; responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
} else if (successCount > 0) { } else if (successCount > 0) {
responseStatus = 207; 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`; 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 { } else {
responseStatus = 207; responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to find any configured Approval Workflow for the author.`; 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) { } catch (error) {
console.error("Workflow creation error:", error); console.error("Workflow creation error:", error);
responseStatus = 207; responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`; 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 } else {
// if exam was updated, log the updates
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id); const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
if (approvalWorkflows) { if (approvalWorkflows) {
const differences = generateExamDifferences(docSnap as Exam, exam as Exam); const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
if (differences) { if (differences) {
approvalWorkflows.forEach((workflow) => { approvalWorkflows.forEach((workflow) => {
const currentStepIndex = workflow.steps.findIndex(step => !step.completed || step.rejected); const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected);
if (workflow.steps[currentStepIndex].examChanges === undefined) { if (workflow.steps[currentStepIndex].examChanges === undefined) {
workflow.steps[currentStepIndex].examChanges = [...differences]; workflow.steps[currentStepIndex].examChanges = [...differences];

View File

@@ -1,11 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {flatten} from "lodash"; import { flatten } from "lodash";
import {Exam} from "@/interfaces/exam"; import { AccessType, Exam } from "@/interfaces/exam";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { requestUser } from "../../../utils/api";
import { mapBy } from "../../../utils";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -14,17 +16,37 @@ export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await GET(req, res); 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) { async function GET(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const user = 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 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) => ({ return snapshot.map((doc) => ({
...doc, ...doc,

View File

@@ -25,8 +25,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },
}); });
console.log('response', response.data);
res.status(response.status).json(response.data); res.status(response.status).json(response.data);
} catch (error) { } catch (error) {
console.error('Error fetching data:', error);
res.status(500).json({ message: 'An unexpected error occurred' }); res.status(500).json({ message: 'An unexpected error occurred' });
} }
} }

View File

@@ -73,13 +73,9 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
})); }));
const editableWorkflow: EditableApprovalWorkflow = { const editableWorkflow: EditableApprovalWorkflow = {
...workflow,
id: workflow._id?.toString() ?? "", id: workflow._id?.toString() ?? "",
name: workflow.name,
entityId: workflow.entityId,
requester: user.id, // should it change to the editor? requester: user.id, // should it change to the editor?
startDate: workflow.startDate,
modules: workflow.modules,
status: workflow.status,
steps: editableSteps, steps: editableSteps,
}; };

View File

@@ -58,7 +58,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
const allAssigneeIds: string[] = [ const allAssigneeIds: string[] = [
...new Set( ...new Set(
workflow.steps workflow.steps
.map(step => step.assignees) .map((step) => {
const assignees = step.assignees;
if (step.completedBy) {
assignees.push(step.completedBy);
}
return assignees;
})
.flat() .flat()
) )
]; ];
@@ -144,7 +150,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const handleApproveStep = () => { const handleApproveStep = () => {
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length); const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
if (isLastStep) { 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 = { const updatedWorkflow: ApprovalWorkflow = {
@@ -186,7 +192,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const examId = currentWorkflow.examId; const examId = currentWorkflow.examId;
axios 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.`)) .then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
@@ -254,10 +260,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
if (examModule && examId) { if (examModule && examId) {
const exam = await getExamById(examModule, examId.trim()); const exam = await getExamById(examModule, examId.trim());
if (!exam) { if (!exam) {
toast.error( toast.error("Something went wrong while fetching exam!");
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{ toastId: "invalid-exam-id" }
);
setViewExamIsLoading(false); setViewExamIsLoading(false);
return; return;
} }
@@ -383,7 +386,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
{/* Side panel */} {/* Side panel */}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<LayoutGroup key="sidePanel"> <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 && ( {isPanelOpen && selectedStep && (
<motion.div <motion.div
className="p-6" className="p-6"
@@ -548,12 +551,16 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="overflow-hidden mt-2" 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?.length ? (
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => ( currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
<p key={index} className="text-sm text-gray-500 mb-2"> <>
{change} <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> </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> <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} value={comments}
onChange={(e) => setComments(e.target.value)} onChange={(e) => setComments(e.target.value)}
placeholder="Input comments here" 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 <Button

View File

@@ -1,24 +1,22 @@
import Tip from "@/components/ApprovalWorkflows/Tip"; import Tip from "@/components/ApprovalWorkflows/Tip";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows"; import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
import { useAllowedEntities, useAllowedEntitiesSomePermissions, useEntityPermission } from "@/hooks/useEntityPermissions";
import { Module, ModuleTypeLabels } from "@/interfaces"; import { Module, ModuleTypeLabels } from "@/interfaces";
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow"; 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 { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be"; 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 { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions"; import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { getSpecificUsers } from "@/utils/users.be"; 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 axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; 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("/"); 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[] = [ const allAssigneeIds: string[] = [
...new Set( ...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 { return {
props: serialize({ props: serialize({
user, user,
@@ -103,7 +101,8 @@ interface Props {
} }
export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: 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 currentWorkflows = workflows || initialWorkflows;
const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]); const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]);
@@ -191,7 +190,15 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
{info.getValue().map((module: Module, index: number) => ( {info.getValue().map((module: Module, index: number) => (
<span <span
key={index} 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]} {ModuleTypeLabels[module]}
</span> </span>
))} ))}
@@ -296,10 +303,20 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
}), }),
]; ];
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({ const table = useReactTable({
data: filteredWorkflows, data: filteredWorkflows,
columns: columns, columns: columns,
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}); });
return ( return (
@@ -395,6 +412,43 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
))} ))}
</tbody> </tbody>
</table> </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> </div>
</> </>
); );

View File

@@ -33,7 +33,6 @@ import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { import {

View File

@@ -143,6 +143,9 @@ export default function AssignmentsPage({
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]); const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
const [showApprovedExams, setShowApprovedExams] = useState<boolean>(true);
const [showNonApprovedExams, setShowNonApprovedExams] = useState<boolean>(true);
const { exams } = useExams(); const { exams } = useExams();
const router = useRouter(); const router = useRouter();
@@ -501,6 +504,23 @@ export default function AssignmentsPage({
Random Exams Random Exams
</Checkbox> </Checkbox>
{!useRandomExams && ( {!useRandomExams && (
<>
<Checkbox
isChecked={showApprovedExams}
onChange={() => {
setShowApprovedExams((prev) => !prev)
}}
>
Show approved exams
</Checkbox>
<Checkbox
isChecked={showNonApprovedExams}
onChange={() => {
setShowNonApprovedExams((prev) => !prev)
}}
>
Show non-approved exams
</Checkbox>
<div className="grid md:grid-cols-2 w-full gap-4"> <div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => ( {selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full"> <div key={module} className="flex flex-col gap-3 w-full">
@@ -508,6 +528,7 @@ export default function AssignmentsPage({
{capitalize(module)} Exam {capitalize(module)} Exam
</label> </label>
<Select <Select
isClearable
value={{ value={{
value: value:
examIDs.find((e) => e.module === module)?.id || examIDs.find((e) => e.module === module)?.id ||
@@ -526,12 +547,21 @@ export default function AssignmentsPage({
) )
} }
options={exams options={exams
.filter((x) => !x.isDiagnostic && x.module === module) .filter((x) =>
!x.isDiagnostic &&
x.module === module &&
x.access !== "confidential" &&
(
(x.requiresApproval && showApprovedExams) ||
(!x.requiresApproval && showNonApprovedExams)
)
)
.map((x) => ({ value: x.id, label: x.id }))} .map((x) => ({ value: x.id, label: x.id }))}
/> />
</div> </div>
))} ))}
</div> </div>
</>
)} )}
</div> </div>
)} )}

View File

@@ -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] 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 = () => { const removeParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return; if (!canRemoveParticipants) return;
@@ -428,6 +443,25 @@ export default function Home({ user, group, users, entity }: Props) {
{capitalize(type)} {capitalize(type)}
</button> </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> </div>
</section> </section>

View File

@@ -1,67 +1,49 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import {useAllowedEntities} from "@/hooks/useEntityPermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import {EntityWithRoles} from "@/interfaces/entity";
import { Stat, StudentUser, Type, User } from "@/interfaces/user"; import {Stat, StudentUser, Type, User} from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { filterBy, mapBy, redirect, serialize } from "@/utils"; import {filterBy, mapBy, redirect, serialize} from "@/utils";
import { requestUser } from "@/utils/api"; import {requestUser} from "@/utils/api";
import { countEntitiesAssignments } from "@/utils/assignments.be"; import {countEntitiesAssignments} from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import {getEntitiesWithRoles} from "@/utils/entities.be";
import { countGroupsByEntities } from "@/utils/groups.be"; import {countGroupsByEntities} from "@/utils/groups.be";
import { import {checkAccess, groupAllowedEntitiesByPermissions} from "@/utils/permissions";
checkAccess, import {groupByExam} from "@/utils/stats";
groupAllowedEntitiesByPermissions, import {countAllowedUsers, getUsers} from "@/utils/users.be";
} from "@/utils/permissions"; import {clsx} from "clsx";
import { groupByExam } from "@/utils/stats"; import {withIronSessionSsr} from "iron-session/next";
import { countAllowedUsers, getUsers } from "@/utils/users.be";
import { clsx } from "clsx";
import { withIronSessionSsr } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import { useMemo } from "react"; import {useMemo} from "react";
import { import {BsBank, BsClock, BsEnvelopePaper, BsPencilSquare, BsPeople, BsPeopleFill, BsPersonFill, BsPersonFillGear} from "react-icons/bs";
BsBank, import {ToastContainer} from "react-toastify";
BsClock, import {isAdmin} from "@/utils/users";
BsEnvelopePaper,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify";
import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
students: StudentUser[]; students: StudentUser[];
latestStudents: User[]; latestStudents: User[];
latestTeachers: User[]; latestTeachers: User[];
userCounts: { [key in Type]: number }; userCounts: {[key in Type]: number};
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
groupsCount: number; groupsCount: number;
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login"); if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/");
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles( const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
isAdmin(user) ? undefined : entityIDS const {["view_students"]: allowedStudentEntities, ["view_teachers"]: allowedTeacherEntities} = groupAllowedEntitiesByPermissions(user, entities, [
);
const {
["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students", "view_students",
"view_teachers", "view_teachers",
]); ]);
@@ -70,37 +52,30 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entitiesIDS = mapBy(entities, "id") || []; const entitiesIDS = mapBy(entities, "id") || [];
const [ const [students, latestStudents, latestTeachers, userCounts, assignmentsCount, groupsCount] = await Promise.all([
students,
latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers( getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, {type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
10, 10,
{ averageLevel: -1 }, {averageLevel: -1},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 } {_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
), ),
getUsers( getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, {type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
10, 10,
{ registrationDate: -1 }, {registrationDate: -1},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 } {_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
), ),
getUsers( getUsers(
{ {
type: "teacher", type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") }, "entities.id": {$in: mapBy(allowedTeacherEntities, "id")},
}, },
10, 10,
{ registrationDate: -1 }, {registrationDate: -1},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 } {_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
), ),
countAllowedUsers(user, entities), countAllowedUsers(user, entities),
countEntitiesAssignments(entitiesIDS, { archived: { $ne: true } }), countEntitiesAssignments(entitiesIDS, {archived: {$ne: true}}),
countGroupsByEntities(entitiesIDS), countGroupsByEntities(entitiesIDS),
]); ]);
@@ -129,37 +104,14 @@ export default function Dashboard({
stats = [], stats = [],
groupsCount, groupsCount,
}: Props) { }: Props) {
const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]);
const totalCount = useMemo( const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]);
() =>
userCounts.corporate +
userCounts.mastercorporate +
userCounts.student +
userCounts.teacher,
[userCounts]
);
const totalLicenses = useMemo(
() =>
entities.reduce(
(acc, curr) => acc + parseInt(curr.licenses.toString()),
0
),
[entities]
);
const router = useRouter(); const router = useRouter();
const allowedEntityStatistics = useAllowedEntities( const allowedEntityStatistics = useAllowedEntities(user, entities, "view_entity_statistics");
user, const allowedStudentPerformance = useAllowedEntities(user, entities, "view_student_performance");
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
return ( return (
<> <>
@@ -197,12 +149,13 @@ export default function Dashboard({
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPeople} Icon={BsBank}
onClick={() => router.push("/classrooms")} onClick={() => router.push("/users?type=mastercorporate")}
label="Classrooms" label="Master Corporates"
value={groupsCount} value={userCounts.mastercorporate}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeople} onClick={() => router.push("/classrooms")} label="Classrooms" value={groupsCount} color="purple" />
<IconCard <IconCard
Icon={BsPeopleFill} Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
@@ -233,19 +186,13 @@ export default function Dashboard({
onClick={() => router.push("/assignments")} onClick={() => router.push("/assignments")}
label="Assignments" label="Assignments"
value={assignmentsCount} value={assignmentsCount}
className={clsx( className={clsx(allowedEntityStatistics.length === 0 && "col-span-2")}
allowedEntityStatistics.length === 0 && "col-span-2"
)}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"
value={ value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose" color="rose"
/> />
</section> </section>
@@ -258,7 +205,7 @@ export default function Dashboard({
users={students.sort( users={students.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)} )}
title="Highest exam count students" title="Highest exam count students"
/> />

View File

@@ -63,6 +63,9 @@ const EXAM_MANAGEMENT: PermissionLayout[] = [
{label: "Generate Level", key: "generate_level"}, {label: "Generate Level", key: "generate_level"},
{label: "Delete Level", key: "delete_level"}, {label: "Delete Level", key: "delete_level"},
{label: "Set as Private/Public", key: "update_exam_privacy"}, {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"}, {label: "View Statistics", key: "view_statistics"},
]; ];

View File

@@ -1,56 +1,93 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {ToastContainer} from "react-toastify"; import { ToastContainer } from "react-toastify";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {Radio, RadioGroup} from "@headlessui/react"; import { Radio, RadioGroup } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {findAllowedEntities} from "@/utils/permissions"; import {
import {User} from "@/interfaces/user"; findAllowedEntities,
findAllowedEntitiesSomePermissions,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { User } from "@/interfaces/user";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor"; import ExamEditor from "@/components/ExamEditor";
import {mapBy, redirect, serialize} from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import {requestUser} from "@/utils/api"; import { requestUser } from "@/utils/api";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {getExam} from "@/utils/exams.be"; import { getExam } from "@/utils/exams.be";
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam"; import {
import {useEffect, useState} from "react"; Exam,
import {getEntitiesWithRoles} from "@/utils/entities.be"; Exercise,
import {isAdmin} from "@/utils/users"; 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 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}) => { export const getServerSideProps = withIronSessionSsr(
async ({ req, res, query }) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return redirect("/login"); if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/"); if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, "id"); const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDs
);
const generatePermissions = groupAllowedEntitiesByPermissions(
user,
entities,
[
"generate_reading",
"generate_listening",
"generate_writing",
"generate_speaking",
"generate_level",
]
);
const permissions: Permission = { const permissions: Permission = {
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0, reading: generatePermissions["generate_reading"].length > 0,
listening: findAllowedEntities(user, entities, `generate_listening`).length > 0, listening: generatePermissions["generate_listening"].length > 0,
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0, writing: generatePermissions["generate_writing"].length > 0,
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0, speaking: generatePermissions["generate_speaking"].length > 0,
level: findAllowedEntities(user, entities, `generate_level`).length > 0, level: generatePermissions["generate_level"].length > 0,
}; };
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy"); const {
console.log(entitiesAllowEditPrivacy); ["update_exam_privacy"]: entitiesAllowEditPrivacy,
["create_confidential_exams"]: entitiesAllowConfExams,
["create_public_exams"]: entitiesAllowPublicExams,
} = groupAllowedEntitiesByPermissions(user, entities, [
"update_exam_privacy",
"create_confidential_exams",
"create_public_exams",
]);
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/"); if (Object.keys(permissions).every((p) => !permissions[p as Module]))
return redirect("/");
const {id, module: examModule} = query as {id?: string; module?: Module}; const { id, module: examModule } = query as {
if (!id || !examModule) return {props: serialize({user, permissions})}; id?: string;
module?: Module;
};
if (!id || !examModule) return { props: serialize({ user, permissions }) };
//if (!permissions[module]) return redirect("/generation") //if (!permissions[module]) return redirect("/generation")
@@ -58,9 +95,20 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) =
if (!exam) return redirect("/generation"); if (!exam) return redirect("/generation");
return { return {
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}), props: serialize({
id,
user,
exam,
examModule,
permissions,
entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}),
}; };
}, sessionOptions); },
sessionOptions
);
export default function Generation({ export default function Generation({
id, id,
@@ -69,6 +117,8 @@ export default function Generation({
examModule, examModule,
permissions, permissions,
entitiesAllowEditPrivacy, entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}: { }: {
id: string; id: string;
user: User; user: User;
@@ -76,12 +126,16 @@ export default function Generation({
examModule?: Module; examModule?: Module;
permissions: Permission; permissions: Permission;
entitiesAllowEditPrivacy: EntityWithRoles[]; entitiesAllowEditPrivacy: EntityWithRoles[];
entitiesAllowPublicExams: EntityWithRoles[];
entitiesAllowConfExams: EntityWithRoles[];
}) { }) {
const {title, currentModule, modules, dispatch} = useExamEditorStore(); const { title, currentModule, modules, dispatch } = useExamEditorStore();
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined); const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
undefined
);
const updateRoot = (updates: Partial<ExamEditorStore>) => { const updateRoot = (updates: Partial<ExamEditorStore>) => {
dispatch({type: "UPDATE_ROOT", payload: {updates}}); dispatch({ type: "UPDATE_ROOT", payload: { updates } });
}; };
useEffect(() => { useEffect(() => {
@@ -89,8 +143,8 @@ export default function Generation({
if (examModule === "level" && exam.module === "level") { if (examModule === "level" && exam.module === "level") {
setExamLevelParts(exam.parts.length); setExamLevelParts(exam.parts.length);
} }
updateRoot({currentModule: examModule}); updateRoot({ currentModule: examModule });
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}}); dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, exam, module]); }, [id, exam, module]);
@@ -98,7 +152,7 @@ export default function Generation({
useEffect(() => { useEffect(() => {
const fetchAvatars = async () => { const fetchAvatars = async () => {
const response = await axios.get("/api/exam/avatars"); const response = await axios.get("/api/exam/avatars");
updateRoot({speakingAvatars: response.data}); updateRoot({ speakingAvatars: response.data });
}; };
fetchAvatars(); fetchAvatars();
@@ -124,14 +178,20 @@ export default function Generation({
sectionId: section.sectionId, sectionId: section.sectionId,
module: "listening", module: "listening",
field: "state", field: "state",
value: {...listeningPart, audio: undefined}, value: { ...listeningPart, audio: undefined },
}, },
}); });
} }
}); });
if (state.listening.instructionsState.customInstructionsURL.startsWith("blob:")) { if (
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL); state.listening.instructionsState.customInstructionsURL.startsWith(
"blob:"
)
) {
URL.revokeObjectURL(
state.listening.instructionsState.customInstructionsURL
);
} }
state.speaking.sections.forEach((section) => { state.speaking.sections.forEach((section) => {
@@ -145,12 +205,13 @@ export default function Generation({
sectionId: section.sectionId, sectionId: section.sectionId,
module: "listening", module: "listening",
field: "state", field: "state",
value: {...speakingExercise, video_url: undefined}, value: { ...speakingExercise, video_url: undefined },
}, },
}); });
} }
if (sectionState.type === "interactiveSpeaking") { if (sectionState.type === "interactiveSpeaking") {
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise; const interactiveSpeaking =
sectionState as InteractiveSpeakingExercise;
interactiveSpeaking.prompts.forEach((prompt) => { interactiveSpeaking.prompts.forEach((prompt) => {
URL.revokeObjectURL(prompt.video_url); URL.revokeObjectURL(prompt.video_url);
}); });
@@ -162,13 +223,16 @@ export default function Generation({
field: "state", field: "state",
value: { value: {
...interactiveSpeaking, ...interactiveSpeaking,
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})), prompts: interactiveSpeaking.prompts.map((p) => ({
...p,
video_url: undefined,
})),
}, },
}, },
}); });
} }
}); });
dispatch({type: "FULL_RESET"}); dispatch({ type: "FULL_RESET" });
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -194,22 +258,25 @@ export default function Generation({
placeholder="Insert a title here" placeholder="Insert a title here"
name="title" name="title"
label="Title" label="Title"
onChange={(title) => updateRoot({title})} onChange={(title) => updateRoot({ title })}
roundness="xl" roundness="xl"
value={title} value={title}
defaultValue={title} defaultValue={title}
required required
/> />
<label className="font-normal text-base text-mti-gray-dim">Module</label> <label className="font-normal text-base text-mti-gray-dim">
Module
</label>
<RadioGroup <RadioGroup
value={currentModule} value={currentModule}
onChange={(currentModule) => updateRoot({currentModule})} onChange={(currentModule) => updateRoot({ currentModule })}
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"> className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
{[...MODULE_ARRAY] >
.filter((m) => permissions[m]) {[...MODULE_ARRAY].reduce((acc, x) => {
.map((x) => ( if (permissions[x])
acc.push(
<Radio value={x} key={x}> <Radio value={x} key={x}>
{({checked}) => ( {({ checked }) => (
<span <span
className={clsx( 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", "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",
@@ -233,16 +300,24 @@ export default function Generation({
x === "level" && x === "level" &&
(!checked (!checked
? "bg-white border-mti-gray-platinum" ? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white"), : "bg-ielts-level/70 border-ielts-level text-white")
)}> )}
>
{capitalize(x)} {capitalize(x)}
</span> </span>
)} )}
</Radio> </Radio>
))} );
return acc;
}, [] as JSX.Element[])}
</RadioGroup> </RadioGroup>
</div> </div>
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} /> <ExamEditor
levelParts={examLevelParts}
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
entitiesAllowConfExams={entitiesAllowConfExams}
entitiesAllowPublicExams={entitiesAllowPublicExams}
/>
</> </>
)} )}
</> </>

View File

@@ -287,7 +287,7 @@ export default function History({
list={filteredStats} list={filteredStats}
renderCard={customContent} renderCard={customContent}
searchFields={[]} searchFields={[]}
pageSize={30} pageSize={25}
className="lg:!grid-cols-3" className="lg:!grid-cols-3"
/> />
)} )}

View File

@@ -1,76 +1,53 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { ToastContainer } from "react-toastify"; import {ToastContainer} from "react-toastify";
import CodeGenerator from "./(admin)/CodeGenerator"; import CodeGenerator from "./(admin)/CodeGenerator";
import ExamLoader from "./(admin)/ExamLoader"; import ExamLoader from "./(admin)/ExamLoader";
import Lists from "./(admin)/Lists"; import Lists from "./(admin)/Lists";
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator"; import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser"; import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import { useState } from "react"; import { useState} from "react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs";
BsCode,
BsCodeSquare,
BsGearFill,
BsPeopleFill,
BsPersonFill,
} from "react-icons/bs";
import UserCreator from "./(admin)/UserCreator"; import UserCreator from "./(admin)/UserCreator";
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem"; import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
import { CEFR_STEPS } from "@/resources/grading"; import {CEFR_STEPS} from "@/resources/grading";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { getUserPermissions } from "@/utils/permissions.be"; import {getUserPermissions} from "@/utils/permissions.be";
import { PermissionType } from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import { getUsers } from "@/utils/users.be"; import {getUsers} from "@/utils/users.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import {getEntitiesWithRoles} from "@/utils/entities.be";
import { mapBy, serialize, redirect } from "@/utils"; import {mapBy, serialize, redirect, filterBy} from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity"; import {EntityWithRoles} from "@/interfaces/entity";
import { requestUser } from "@/utils/api"; import {requestUser} from "@/utils/api";
import { isAdmin } from "@/utils/users"; import {isAdmin} from "@/utils/users";
import { import {getGradingSystemByEntities, getGradingSystemByEntity} from "@/utils/grading.be";
getGradingSystemByEntities, import {Grading} from "@/interfaces";
getGradingSystemByEntity, import {useRouter} from "next/router";
} from "@/utils/grading.be"; import {useAllowedEntities} from "@/hooks/useEntityPermissions";
import { Grading } from "@/interfaces";
import { useRouter } from "next/router";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return redirect("/login"); if (!user) return redirect("/login");
if ( if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) return redirect("/");
shouldRedirectHome(user) ||
!checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/");
const [permissions, entities, allUsers] = await Promise.all([ const [permissions, entities, allUsers] = await Promise.all([
getUserPermissions(user.id), getUserPermissions(user.id),
isAdmin(user) isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, "id")),
? await getEntitiesWithRoles()
: await getEntitiesWithRoles(mapBy(user.entities, "id")),
getUsers(), getUsers(),
]); ]);
const gradingSystems = await getGradingSystemByEntities( const gradingSystems = await getGradingSystemByEntities(mapBy(entities, "id"));
mapBy(entities, "id")
);
const entitiesGrading = entities.map( const entitiesGrading = entities.map(
(e) => (e) =>
gradingSystems.find((g) => g.entity === e.id) || { gradingSystems.find((g) => g.entity === e.id) || {
entity: e.id, entity: e.id,
steps: CEFR_STEPS, steps: CEFR_STEPS,
} },
); );
return { return {
@@ -92,41 +69,15 @@ interface Props {
entitiesGrading: Grading[]; entitiesGrading: Grading[];
} }
export default function Admin({ export default function Admin({user, entities, permissions, allUsers, entitiesGrading}: Props) {
user,
entities,
permissions,
allUsers,
entitiesGrading,
}: Props) {
const [modalOpen, setModalOpen] = useState<string>(); const [modalOpen, setModalOpen] = useState<string>();
const router = useRouter(); const router = useRouter();
const entitiesAllowCreateUser = useAllowedEntities( const entitiesAllowCreateUser = useAllowedEntities(user, entities, "create_user");
user, const entitiesAllowCreateUsers = useAllowedEntities(user, entities, "create_user_batch");
entities, const entitiesAllowCreateCode = useAllowedEntities(user, entities, "create_code");
"create_user" const entitiesAllowCreateCodes = useAllowedEntities(user, entities, "create_code_batch");
); const entitiesAllowEditGrading = useAllowedEntities(user, entities, "edit_grading_system");
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 ( return (
<> <>
@@ -141,22 +92,19 @@ export default function Admin({
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<Modal <Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]">
isOpen={modalOpen === "batchCreateUser"}
onClose={() => setModalOpen(undefined)}
maxWidth="max-w-[85%]"
>
<BatchCreateUser <BatchCreateUser
user={user} user={user}
entities={entitiesAllowCreateUser} 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} permissions={permissions}
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal <Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
isOpen={modalOpen === "batchCreateCode"}
onClose={() => setModalOpen(undefined)}
>
<BatchCodeGenerator <BatchCodeGenerator
entities={entitiesAllowCreateCodes} entities={entitiesAllowCreateCodes}
user={user} user={user}
@@ -165,10 +113,7 @@ export default function Admin({
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal <Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
isOpen={modalOpen === "createCode"}
onClose={() => setModalOpen(undefined)}
>
<CodeGenerator <CodeGenerator
entities={entitiesAllowCreateCode} entities={entitiesAllowCreateCode}
user={user} user={user}
@@ -176,22 +121,20 @@ export default function Admin({
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal <Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
isOpen={modalOpen === "createUser"}
onClose={() => setModalOpen(undefined)}
>
<UserCreator <UserCreator
user={user} user={user}
entities={entitiesAllowCreateUsers} 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} users={allUsers}
permissions={permissions} permissions={permissions}
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal <Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
isOpen={modalOpen === "gradingSystem"}
onClose={() => setModalOpen(undefined)}
>
<CorporateGradingSystem <CorporateGradingSystem
user={user} user={user}
entitiesGrading={entitiesGrading} entitiesGrading={entitiesGrading}
@@ -202,12 +145,7 @@ export default function Admin({
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8"> <section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader /> <ExamLoader />
{checkAccess( {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
user,
getTypesOfUser(["teacher"]),
permissions,
"viewCodes"
) && (
<div className="w-full grid grid-cols-2 gap-4"> <div className="w-full grid grid-cols-2 gap-4">
<IconCard <IconCard
Icon={BsCode} Icon={BsCode}
@@ -241,12 +179,7 @@ export default function Admin({
onClick={() => setModalOpen("batchCreateUser")} onClick={() => setModalOpen("batchCreateUser")}
disabled={entitiesAllowCreateUsers.length === 0} disabled={entitiesAllowCreateUsers.length === 0}
/> />
{checkAccess(user, [ {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && (
"admin",
"corporate",
"developer",
"mastercorporate",
]) && (
<IconCard <IconCard
Icon={BsGearFill} Icon={BsGearFill}
label="Grading System" label="Grading System"

View File

@@ -203,18 +203,6 @@ const Training: React.FC<{
</Head> </Head>
<ToastContainer /> <ToastContainer />
<>
{isNewContentLoading || areRecordsLoading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">
Assessing your exams, please be patient...
</span>
)}
</div>
) : (
<>
<RecordFilter <RecordFilter
entities={entities} entities={entities}
user={user} user={user}
@@ -241,12 +229,22 @@ const Training: React.FC<{
</> </>
)} )}
</RecordFilter> </RecordFilter>
<>
{isNewContentLoading || areRecordsLoading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">
Assessing your exams, please be patient...
</span>
)}
</div>
) : (
<>
{trainingContent.length == 0 && ( {trainingContent.length == 0 && (
<div className="flex flex-grow justify-center items-center">
<span className="font-semibold ml-1"> <span className="font-semibold ml-1">
No training content to display... No training content to display...
</span> </span>
</div>
)} )}
{!areRecordsLoading && {!areRecordsLoading &&
groupedByTrainingContent && groupedByTrainingContent &&

View File

@@ -30,7 +30,6 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
entities, entities,
"view_student_performance" "view_student_performance"
); );
if (allowedEntities.length === 0) return redirect("/"); if (allowedEntities.length === 0) return redirect("/");
const students = await (checkAccess(user, ["admin", "developer"]) const students = await (checkAccess(user, ["admin", "developer"])
@@ -58,10 +57,11 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
const performanceStudents = students.map((u) => ({ const performanceStudents = students.map((u) => ({
...u, ...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A", group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
entitiesLabel: mapBy(u.entities, "id") entitiesLabel: (u.entities || []).reduce((acc, curr, idx) => {
.map((id) => entities.find((e) => e.id === id)?.label) const entity = entities.find((e) => e.id === curr.id);
.filter((e) => !!e) if (idx === 0) return entity ? entity.label : "";
.join(", "), return acc + (entity ? `${entity.label}` : "");
}, ""),
})); }));
return ( return (

View File

@@ -71,7 +71,10 @@ export type RolePermission =
| "view_workflows" | "view_workflows"
| "configure_workflows" | "configure_workflows"
| "edit_workflow" | "edit_workflow"
| "delete_workflow"; | "delete_workflow"
| "view_confidential_exams"
| "create_confidential_exams"
| "create_public_exams";
export const DEFAULT_PERMISSIONS: RolePermission[] = [ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students", "view_students",
@@ -156,4 +159,6 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
"view_workflows", "view_workflows",
"edit_workflow", "edit_workflow",
"delete_workflow", "delete_workflow",
"create_confidential_exams",
"create_public_exams",
]; ];

View File

@@ -14,7 +14,7 @@ export type RootActions =
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } | { type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } |
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } | { type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
{ type: 'FINALIZE_MODULE_SOLUTIONS' } | { type: 'FINALIZE_MODULE_SOLUTIONS' } |
{ type: 'UPDATE_EXAMS'} { type: 'UPDATE_EXAMS' }
export type Action = RootActions | SessionActions; export type Action = RootActions | SessionActions;

View File

@@ -158,7 +158,7 @@ const defaultModuleSettings = (module: Module, minTimer: number, reset: boolean
examLabel: defaultExamLabel(module), examLabel: defaultExamLabel(module),
minTimer, minTimer,
difficulty: [sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!], difficulty: [sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!],
isPrivate: true, access: "private",
sectionLabels: sectionLabels(module), sectionLabels: sectionLabels(module),
expandedSections: [(reset && (module === "writing" || module === "speaking")) ? 0 : 1], expandedSections: [(reset && (module === "writing" || module === "speaking")) ? 0 : 1],
focusedSection: 1, focusedSection: 1,

View File

@@ -6,6 +6,7 @@ import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReduce
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { updateExamWithUserSolutions } from "@/stores/exam/utils"; import { updateExamWithUserSolutions } from "@/stores/exam/utils";
import { defaultExamUserSolutions } from "@/utils/exams"; import { defaultExamUserSolutions } from "@/utils/exams";
import { access } from "fs";
type RootActions = { type: 'FULL_RESET' } | type RootActions = { type: 'FULL_RESET' } |
{ type: 'INIT_EXAM_EDIT', payload: { exam: Exam; examModule: Module; id: string } } | { type: 'INIT_EXAM_EDIT', payload: { exam: Exam; examModule: Module; id: string } } |
@@ -121,7 +122,7 @@ export const rootReducer = (
...defaultModuleSettings(examModule, exam.minTimer), ...defaultModuleSettings(examModule, exam.minTimer),
examLabel: exam.label, examLabel: exam.label,
difficulty: exam.difficulty, difficulty: exam.difficulty,
isPrivate: exam.private, access: exam.access,
sections: examState, sections: examState,
importModule: false, importModule: false,
sectionLabels: sectionLabels:

View File

@@ -59,8 +59,8 @@ const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): Reo
let newIds = oldIds.map((_, index) => (startId + index).toString()); let newIds = oldIds.map((_, index) => (startId + index).toString());
let newSolutions = exercise.solutions.map((solution, index) => ({ let newSolutions = exercise.solutions.map((solution, index) => ({
id: newIds[index], ...solution,
solution: [...solution.solution] id: newIds[index]
})); }));
let newText = exercise.text; let newText = exercise.text;

View File

@@ -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 { Module } from "@/interfaces";
import Option from "@/interfaces/option"; 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 Context = "passage" | "video" | "audio" | "listeningScript" | "speakingScript" | "writing";
export type Generating = Context | "exercises" | string | undefined; 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 Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
export type ExamPart = ListeningPart | ReadingPart | LevelPart; export type ExamPart = ListeningPart | ReadingPart | LevelPart;
@@ -97,10 +97,10 @@ export interface SectionState {
state: Section; state: Section;
expandedSubSections: number[]; expandedSubSections: number[];
generating: Generating; generating: Generating;
genResult: {generating: string, result: Record<string, any>[], module: Module} | undefined; genResult: { generating: string, result: Record<string, any>[], module: Module } | undefined;
levelGenerating: Generating[]; levelGenerating: Generating[];
levelGenResults: LevelGenResults[]; levelGenResults: LevelGenResults[];
focusedExercise?: {questionId: number; id: string} | undefined; focusedExercise?: { questionId: number; id: string } | undefined;
writingSection?: number; writingSection?: number;
speakingSection?: number; speakingSection?: number;
readingSection?: number; readingSection?: number;
@@ -126,8 +126,8 @@ export interface ModuleState {
sections: SectionState[]; sections: SectionState[];
minTimer: number; minTimer: number;
difficulty: Difficulty[]; difficulty: Difficulty[];
isPrivate: boolean; access: AccessType;
sectionLabels: {id: number; label: string;}[]; sectionLabels: { id: number; label: string; }[];
expandedSections: number[]; expandedSections: number[];
focusedSection: number; focusedSection: number;
importModule: boolean; importModule: boolean;

View File

@@ -4,10 +4,25 @@ import { ObjectId } from "mongodb";
const db = client.db(process.env.MONGODB_DB); 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 return await db
.collection<ApprovalWorkflow>(collection) .collection<ApprovalWorkflow>(collection)
.find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {}) .find(filters)
.sort({ startDate: -1 })
.toArray(); .toArray();
}; };
@@ -19,6 +34,7 @@ export const getApprovalWorkflowsByEntities = async (collection: string, ids: st
return await db return await db
.collection<ApprovalWorkflow>(collection) .collection<ApprovalWorkflow>(collection)
.find({ entityId: { $in: ids } }) .find({ entityId: { $in: ids } })
.sort({ startDate: -1 })
.toArray(); .toArray();
}; };
@@ -39,7 +55,7 @@ export const getApprovalWorkflowsByExamId = async (examId: string) => {
.collection<ApprovalWorkflow>("active-workflows") .collection<ApprovalWorkflow>("active-workflows")
.find({ .find({
examId, examId,
status: { $in: ["pending"] } status: { $in: ["pending"] },
}) })
.toArray(); .toArray();
}; };

View File

@@ -1,84 +1,168 @@
import { Exam } from "@/interfaces/exam"; 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[] { export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
const differences = diff(oldExam, newExam) || []; const differences: string[] = [];
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[]; compareObjects(oldExam, newExam, [], differences);
return differences;
} }
function formatDifference(change: Diff<any, any>): string | undefined { function isObject(val: any): val is Record<string, any> {
if (!change.path) { return val !== null && typeof val === "object" && !Array.isArray(val);
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 formatArrayChange(change: Diff<any, any>): string | undefined { function formatPrimitive(value: any): string {
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";
if (value === undefined) return "undefined"; if (value === undefined) return "undefined";
if (typeof value === "object") { if (value === null) return "null";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return JSON.stringify(value); 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`);
}
}
}

View File

@@ -64,6 +64,7 @@ export const getExams = async (
.collection(module) .collection(module)
.find<Exam>({ .find<Exam>({
isDiagnostic: false, isDiagnostic: false,
access: "public",
}) })
.toArray(); .toArray();
@@ -72,7 +73,7 @@ export const getExams = async (
...doc, ...doc,
module, module,
})) as Exam[], })) as Exam[],
).filter((x) => !x.private); )
let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId); let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId);
exams = filterByVariant(exams, variant); exams = filterByVariant(exams, variant);

View File

@@ -23,7 +23,7 @@ export const sortByModuleName = (a: string, b: string) => {
}; };
export const countExercises = (exercises: Exercise[]) => { 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 === "multipleChoice") return e.questions.length;
if (e.type === "interactiveSpeaking") return e.prompts.length; if (e.type === "interactiveSpeaking") return e.prompts.length;
if (e.type === "fillBlanks") return e.solutions.length; if (e.type === "fillBlanks") return e.solutions.length;
@@ -40,7 +40,7 @@ export const countCurrentExercises = (
exercises: Exercise[], exercises: Exercise[],
exerciseIndex: number, exerciseIndex: number,
questionIndex?: number questionIndex?: number
) => { ) => {
return exercises.reduce((acc, exercise, index) => { return exercises.reduce((acc, exercise, index) => {
if (index > exerciseIndex) { if (index > exerciseIndex) {
return acc; return acc;
@@ -70,7 +70,7 @@ export const countCurrentExercises = (
return acc + count; return acc + count;
}, 0); }, 0);
}; };
export const countFullExams = (stats: Stat[]) => { export const countFullExams = (stats: Stat[]) => {
const sessionExams = groupBySession(stats); const sessionExams = groupBySession(stats);

View File

@@ -2,7 +2,6 @@ import { EntityWithRoles, Role } from "@/interfaces/entity";
import { PermissionType } from "@/interfaces/permissions"; import { PermissionType } from "@/interfaces/permissions";
import { User, Type, userTypes } from "@/interfaces/user"; import { User, Type, userTypes } from "@/interfaces/user";
import { RolePermission } from "@/resources/entityPermissions"; import { RolePermission } from "@/resources/entityPermissions";
import axios from "axios";
import { findBy, mapBy } from "."; import { findBy, mapBy } from ".";
import { isAdmin } from "./users"; import { isAdmin } from "./users";
@@ -76,7 +75,7 @@ export function groupAllowedEntitiesByPermissions(
export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) { export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) {
if (["admin", "developer"].includes(user?.type)) return entities 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 return allowedEntities
} }

View File

@@ -6,7 +6,7 @@ import client from "@/lib/mongodb";
import { EntityWithRoles, WithEntities } from "@/interfaces/entity"; import { EntityWithRoles, WithEntities } from "@/interfaces/entity";
import { getEntity } from "./entities.be"; import { getEntity } from "./entities.be";
import { getRole } from "./roles.be"; import { getRole } from "./roles.be";
import { findAllowedEntities, groupAllowedEntitiesByPermissions } from "./permissions"; import { groupAllowedEntitiesByPermissions } from "./permissions";
import { mapBy } from "."; import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -266,12 +266,13 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
'view_corporates', 'view_corporates',
'view_mastercorporates', 'view_mastercorporates',
]); ]);
console.log(mapBy(allowedStudentEntities, 'id'))
const [student, teacher, corporate, mastercorporate] = await Promise.all([ const [student, teacher, corporate, mastercorporate] = await Promise.all([
countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }), countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }), countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }), countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }), countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
]) ])
console.log(student)
return { student, teacher, corporate, mastercorporate } return { student, teacher, corporate, mastercorporate }
} }

View File

@@ -1,8 +1,9 @@
import {WithLabeledEntities} from "@/interfaces/entity"; import { WithLabeledEntities } from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import ExcelJS from "exceljs";
export interface UserListRow { export interface UserListRow {
name: string; name: string;
@@ -17,6 +18,22 @@ export interface UserListRow {
gender: string; 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>[]) => { export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
const rows: UserListRow[] = rowUsers.map((user) => ({ const rows: UserListRow[] = rowUsers.map((user) => ({
name: user.name, name: user.name,
@@ -33,10 +50,31 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A", gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
verified: user.isVerified?.toString() || "FALSE", verified: user.isVerified?.toString() || "FALSE",
})); }));
const header = "Name,Email,Type,Entities,Expiry Date,Country,Phone,Employment/Department,Gender,Verification"; const workbook = new ExcelJS.Workbook();
const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n"); 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) => { export const getUserName = (user?: User) => {