Compare commits

...

17 Commits

Author SHA1 Message Date
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
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
47 changed files with 2703 additions and 1972 deletions

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

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

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

View File

@@ -1,11 +1,10 @@
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 { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam"; import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify"; import { 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";
@@ -21,13 +20,36 @@ 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 ModuleSettings: Record<Module, React.ComponentType> = {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings,
};
const ExamEditor: React.FC<{ const ExamEditor: React.FC<{
levelParts?: number; levelParts?: number;
entitiesAllowEditPrivacy: EntityWithRoles[]; entitiesAllowEditPrivacy: EntityWithRoles[];
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => { entitiesAllowConfExams: EntityWithRoles[];
entitiesAllowPublicExams: EntityWithRoles[];
}> = ({
levelParts = 0,
entitiesAllowEditPrivacy = [],
entitiesAllowConfExams = [],
entitiesAllowPublicExams = [],
}) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const { const {
sections, sections,
@@ -111,7 +133,10 @@ const ExamEditor: React.FC<{
// 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>) => {
@@ -120,29 +145,42 @@ const ExamEditor: React.FC<{
[dispatch] [dispatch]
); );
const toggleSection = (sectionId: number) => { const toggleSection = useCallback(
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { (sectionId: number) => {
toast.error("Include at least one section!"); if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
return; toast.error("Include at least one section!");
return;
}
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
},
[dispatch, expandedSections, sectionIds]
);
const Settings = useMemo(
() => ModuleSettings[currentModule],
[currentModule]
);
const showImport = useMemo(
() =>
importModule && ["reading", "listening", "level"].includes(currentModule),
[importModule, currentModule]
);
const accessTypeOptions = useMemo(() => {
let options: Option[] = [{ value: "private", label: "Private" }];
if (entitiesAllowConfExams.length > 0) {
options.push({ value: "confidential", label: "Confidential" });
} }
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } }); if (entitiesAllowPublicExams.length > 0) {
}; options.push({ value: "public", label: "Public" });
}
return options;
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
const ModuleSettings: Record<Module, React.ComponentType> = { const updateLevelParts = useCallback((parts: number) => {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings,
};
const Settings = ModuleSettings[currentModule];
const showImport =
importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts); setNumberOfLevelParts(parts);
}; }, []);
return ( return (
<> <>
@@ -161,9 +199,14 @@ const ExamEditor: React.FC<{
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",
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"> <label className="font-normal text-base text-mti-gray-dim">
Timer Timer
</label> </label>
@@ -176,19 +219,16 @@ const ExamEditor: React.FC<{
}) })
} }
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"> <label className="font-normal text-base text-mti-gray-dim">
Difficulty Difficulty
</label> </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 const selectedDifficulties = values
? values.map((v) => v.value as Difficulty) ? values.map((v) => v.value as Difficulty)
@@ -214,12 +254,12 @@ const ExamEditor: React.FC<{
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
{sectionLabels[0].label.split(" ")[0]} {sectionLabels[0].label.split(" ")[0]}
</label> </label>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-3">
{sectionLabels.map(({ id, label }) => ( {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`
@@ -246,22 +286,24 @@ const ExamEditor: React.FC<{
/> />
</div> </div>
)} )}
</div> <div className="max-w-[200px] w-full">
<div className="flex flex-row gap-3 w-64"> <Select
<Select label="Access Type"
label="Access Type" disabled={
options={ACCESSTYPE.map((item) => ({ accessTypeOptions.length === 0 ||
value: item, entitiesAllowEditPrivacy.length === 0
label: capitalize(item),
}))}
onChange={(value) => {
if (value?.value) {
updateModule({ access: value.value! as AccessType });
} }
}} options={accessTypeOptions}
value={{ value: access, label: capitalize(access) }} onChange={(value) => {
/> if (value?.value) {
updateModule({ access: value.value! as AccessType });
}
}}
value={{ value: access, label: capitalize(access) }}
/>
</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"> <label className="font-normal text-base text-mti-gray-dim">
@@ -286,7 +328,7 @@ const ExamEditor: React.FC<{
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

@@ -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]);
@@ -68,7 +66,15 @@ const RecordFilter: React.FC<Props> = ({
entities, entities,
"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

@@ -38,10 +38,7 @@ export default function usePagination<T>(list: T[], size = 25) {
<Select <Select
value={{ value={{
value: itemsPerPage.toString(), value: itemsPerPage.toString(),
label: (itemsPerPage * page > items.length label: itemsPerPage.toString(),
? items.length
: itemsPerPage * page
).toString(),
}} }}
onChange={(value) => onChange={(value) =>
setItemsPerPage(parseInt(value!.value ?? "25")) setItemsPerPage(parseInt(value!.value ?? "25"))

View File

@@ -29,6 +29,7 @@ export interface ExamBase {
access: AccessType; access: AccessType;
label?: string; label?: string;
requiresApproval?: boolean; requiresApproval?: boolean;
approved?: boolean;
} }
export interface ReadingExam extends ExamBase { export interface ReadingExam extends ExamBase {
module: "reading"; module: "reading";
@@ -241,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;
@@ -258,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"
}[]; }[];
@@ -281,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"
@@ -293,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)
}[]; }[];
@@ -319,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;
} }
@@ -346,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

@@ -68,14 +68,14 @@ export async function createApprovalWorkflowOnExamCreation(examAuthor: string, e
} }
} }
// prettier-ignore // commented because they asked for every exam to stay confidential
if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process /* if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
await db.collection(examModule).updateOne( await db.collection(examModule).updateOne(
{ id: examId }, { id: examId },
{ $set: { id: examId, isDiagnostic: false }}, { $set: { id: examId, access: "private" }},
{ upsert: true } { upsert: true }
); );
} } */
return { return {
successCount, successCount,

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,444 +13,587 @@ 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[] };
} = { } = {
student: { student: {
perm: "createCodeStudent", perm: "createCodeStudent",
list: [], list: [],
}, },
teacher: { teacher: {
perm: "createCodeTeacher", perm: "createCodeTeacher",
list: [], list: [],
}, },
agent: { agent: {
perm: "createCodeCountryManager", perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"], list: ["student", "teacher", "corporate", "mastercorporate"],
}, },
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
list: ["student", "teacher"], list: ["student", "teacher"],
}, },
mastercorporate: { mastercorporate: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "corporate"], list: ["student", "teacher", "corporate"],
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: [
}, "student",
developer: { "teacher",
perm: undefined, "agent",
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], "corporate",
}, "admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
}; };
interface Props { 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,
const [isLoading, setIsLoading] = useState(false); users,
const [expiryDate, setExpiryDate] = useState<Date | null>( entities = [],
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, permissions,
); onFinish,
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); }: Props) {
const [type, setType] = useState<Type>("student"); const [infos, setInfos] = useState<
const [showHelp, setShowHelp] = useState(false); { email: string; name: string; passport_id: string }[]
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined); >([]);
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined }); const [isLoading, setIsLoading] = useState(false);
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>(); const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).toDate()
: null
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const [parsedExcel, setParsedExcel] = useState<{
rows?: any[];
errors?: any[];
}>({ rows: undefined, errors: undefined });
const [duplicatedRows, setDuplicatedRows] = useState<{
duplicates: ExcelCodegenDuplicatesMap;
count: number;
}>();
const { openFilePicker, filesContent, clear } = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [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': { "Last Name": {
prop: 'lastName', 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': { "Passport/National ID": {
prop: 'passport_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': { "E-mail": {
prop: 'email', 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]);
useEffect(() => { useEffect(() => {
if (parsedExcel.rows) { if (parsedExcel.rows) {
const duplicates: ExcelCodegenDuplicatesMap = { const duplicates: ExcelCodegenDuplicatesMap = {
email: new Map(), email: new Map(),
passport_id: new Map(), passport_id: new Map(),
}; };
const duplicateValues = new Set<string>(); const duplicateValues = new Set<string>();
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 => { (
if (row !== null) { Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
const value = row[field]; ).forEach((field) => {
if (value) { if (row !== null) {
if (!duplicates[field].has(value)) { const value = row[field];
duplicates[field].set(value, [index + 2]); if (value) {
} else { if (!duplicates[field].has(value)) {
const existingRows = duplicates[field].get(value); duplicates[field].set(value, [index + 2]);
if (existingRows) { } else {
existingRows.push(index + 2); const existingRows = duplicates[field].get(value);
duplicateValues.add(value); if (existingRows) {
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum)); existingRows.push(index + 2);
} duplicateValues.add(value);
} existingRows.forEach((rowNum) =>
} duplicateRowIndices.add(rowNum)
} );
}); }
} }
}); }
}
});
}
});
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 (
return undefined; errorRowIndices.has(index + 2) ||
} duplicateRowIndices.has(index + 2) ||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row; row === null
if (!email || !EMAIL_REGEX.test(email.toString().trim())) { ) {
return undefined; return undefined;
} }
const {
firstName,
lastName,
studentID,
passport_id,
email,
phone,
group,
country,
} = row;
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
return undefined;
}
return { return {
email: email.toString().trim().toLowerCase(), email: email.toString().trim().toLowerCase(),
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(
const existingUsers = infos (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)) const existingUsers = infos
.filter((x) => !!x && x.type === "student") as User[]; .filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[];
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined; const newUsersSentence =
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined; newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
if ( const existingUsersSentence =
!confirm( existingUsers.length > 0
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`, ? `invite ${existingUsers.length} registered student(s)`
) : undefined;
) if (
return; !confirm(
`You are about to ${[newUsersSentence, existingUsersSentence]
.filter((x) => !!x)
.join(" and ")}, are you sure you want to continue?`
)
)
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(
.finally(() => { async (u) =>
if (newUsers.length === 0) setIsLoading(false); await axios.post(`/api/invites`, { to: u.id, from: user.id })
}); )
)
.then(() =>
toast.success(
`Successfully invited ${existingUsers.length} registered student(s)!`
)
)
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
if (newUsers.length > 0) generateCode(type, newUsers); if (newUsers.length > 0) generateCode(type, newUsers);
setInfos([]); setInfos([]);
}; };
const generateCode = (type: Type, informations: typeof infos) => { const generateCode = (type: Type, informations: typeof infos) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const codes = informations.map(() => uid.randomUUID(6)); const codes = informations.map(() => uid.randomUUID(6));
setIsLoading(true); setIsLoading(true);
axios axios
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", { .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
type, type,
codes, codes,
infos: informations.map((info, index) => ({ ...info, code: codes[index] })), infos: informations.map((info, index) => ({
expiryDate, ...info,
entity code: codes[index],
}) })),
.then(({ data, status }) => { expiryDate,
if (data.ok) { entity,
toast.success( })
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize( .then(({ data, status }) => {
type, if (data.ok) {
)} codes and they have been notified by e-mail!`, toast.success(
{ toastId: "success" }, `Successfully generated${
); data.valid ? ` ${data.valid}/${informations.length}` : ""
} ${capitalize(type)} codes and they have been notified by e-mail!`,
{ toastId: "success" }
);
onFinish(); onFinish();
return; return;
} }
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, { toastId: "forbidden" });
} }
}) })
.catch(({ response: { status, data } }) => { .catch(({ response: { status, data } }) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, { toastId: "forbidden" });
return; return;
} }
toast.error(`Something went wrong, please try again later!`, { toast.error(`Something went wrong, please try again later!`, {
toastId: "error", toastId: "error",
}); });
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
return clear(); return clear();
}); });
}; };
const handleTemplateDownload = () => { const handleTemplateDownload = () => {
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;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}; };
return ( return (
<> <>
<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">
<div className="mt-4 flex flex-col gap-4"> <span>Excel File Format</span>
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4"> </div>
<div className="flex items-center gap-2"> <div className="mt-4 flex flex-col gap-4">
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} /> <div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<h2 className="text-lg font-semibold"> <div className="flex items-center gap-2">
The uploaded document must: <HiOutlineDocumentText
</h2> className={`w-5 h-5 text-mti-purple-light`}
</div> />
<ul className="flex flex-col pl-10 gap-2"> <h2 className="text-lg font-semibold">
<li className="text-gray-700 list-disc"> The uploaded document must:
be an Excel .xlsx document. </h2>
</li> </div>
<li className="text-gray-700 list-disc"> <ul className="flex flex-col pl-10 gap-2">
only have a single spreadsheet with the following <b>exact same name</b> columns: <li className="text-gray-700 list-disc">
<div className="py-4 pr-4"> be an Excel .xlsx document.
<table className="w-full bg-white"> </li>
<thead> <li className="text-gray-700 list-disc">
<tr> only have a single spreadsheet with the following{" "}
<th className="border border-neutral-200 px-2 py-1">First Name</th> <b>exact same name</b> columns:
<th className="border border-neutral-200 px-2 py-1">Last Name</th> <div className="py-4 pr-4">
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> <table className="w-full bg-white">
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <thead>
</tr> <tr>
</thead> <th className="border border-neutral-200 px-2 py-1">
</table> First Name
</div> </th>
</li> <th className="border border-neutral-200 px-2 py-1">
</ul> Last Name
</div> </th>
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4"> <th className="border border-neutral-200 px-2 py-1">
<div className="flex items-center gap-2"> Passport/National ID
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} /> </th>
<h2 className="text-lg font-semibold"> <th className="border border-neutral-200 px-2 py-1">
Note that: E-mail
</h2> </th>
</div> </tr>
<ul className="flex flex-col pl-10 gap-2"> </thead>
<li className="text-gray-700 list-disc"> </table>
all incorrect e-mails will be ignored. </div>
</li> </li>
<li className="text-gray-700 list-disc"> </ul>
all already registered e-mails will be ignored. </div>
</li> <div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<li className="text-gray-700 list-disc"> <div className="flex items-center gap-2">
all rows which contain duplicate values in the columns: &quot;Passport/National ID&quot;, &quot;E-mail&quot;, will be ignored. <IoInformationCircleOutline
</li> className={`w-5 h-5 text-mti-purple-light`}
<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. <h2 className="text-lg font-semibold">Note that:</h2>
</li> </div>
</ul> <ul className="flex flex-col pl-10 gap-2">
</div> <li className="text-gray-700 list-disc">
<div className="bg-gray-100 rounded-lg p-4"> all incorrect e-mails will be ignored.
<p className="text-gray-600"> </li>
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`} <li className="text-gray-700 list-disc">
</p> all already registered e-mails will be ignored.
</div> </li>
<div className="w-full flex justify-between mt-6 gap-8"> <li className="text-gray-700 list-disc">
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white"> all rows which contain duplicate values in the columns:
Close &quot;Passport/National ID&quot;, &quot;E-mail&quot;, will be
</Button> ignored.
</li>
<li className="text-gray-700 list-disc">
all of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li>
</ul>
</div>
<div className="bg-gray-100 rounded-lg p-4">
<p className="text-gray-600">
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
</p>
</div>
<div className="w-full flex justify-between mt-6 gap-8">
<Button
color="purple"
onClick={() => setShowHelp(false)}
variant="outline"
className="self-end w-full bg-white"
>
Close
</Button>
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full"> <Button
<div className="flex items-center gap-2"> color="purple"
<FaFileDownload size={24} /> onClick={handleTemplateDownload}
Download Template variant="solid"
</div> className="self-end w-full"
</Button> >
</div> <div className="flex items-center gap-2">
</div> <FaFileDownload size={24} />
</> Download Template
</Modal> </div>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> </Button>
<div className="flex items-end justify-between"> </div>
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label> </div>
<button </>
onClick={() => setShowHelp(true)} </Modal>
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200" <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
data-tip="Excel File Format" <div className="flex items-end justify-between">
> <label className="text-mti-gray-dim text-base font-normal">
<IoInformationCircleOutline size={24} /> Choose an Excel file
</button> </label>
</div> <button
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> onClick={() => setShowHelp(true)}
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
</Button> data-tip="Excel File Format"
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( >
<> <IoInformationCircleOutline size={24} />
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> </button>
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> </div>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> <Button
Enabled onClick={openFilePicker}
</Checkbox> isLoading={isLoading}
</div> disabled={isLoading}
{isExpiryDateEnabled && ( >
<ReactDatePicker {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
className={clsx( </Button>
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", {user &&
"hover:border-mti-purple tooltip", checkAccess(user, [
"transition duration-300 ease-in-out", "developer",
)} "admin",
filterDate={(date) => "corporate",
moment(date).isAfter(new Date()) && "mastercorporate",
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) ]) && (
} <>
dateFormat="dd/MM/yyyy" <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
selected={expiryDate} <label className="text-mti-gray-dim text-base font-normal">
onChange={(date) => setExpiryDate(date)} Expiry Date
/> </label>
)} <Checkbox
</> isChecked={isExpiryDateEnabled}
)} onChange={setIsExpiryDateEnabled}
<div className={clsx("flex flex-col gap-4")}> disabled={!!user.subscriptionExpirationDate}
<label className="font-normal text-base text-mti-gray-dim">Entity</label> >
<Select Enabled
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }} </Checkbox>
options={entities.map((e) => ({ value: e.id, label: e.label }))} </div>
onChange={(e) => setEntity(e?.value || undefined)} {isExpiryDateEnabled && (
isClearable={checkAccess(user, ["admin", "developer"])} <ReactDatePicker
/> className={clsx(
</div> "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label> "hover:border-mti-purple tooltip",
{user && ( "transition duration-300 ease-in-out"
<select )}
defaultValue="student" filterDate={(date) =>
onChange={(e) => setType(e.target.value as typeof user.type)} moment(date).isAfter(new Date()) &&
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"> (user.subscriptionExpirationDate
{Object.keys(USER_TYPE_LABELS) ? moment(date).isBefore(user.subscriptionExpirationDate)
.filter((x) => { : true)
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; }
return checkAccess(user, getTypesOfUser(list), permissions, perm); dateFormat="dd/MM/yyyy"
}) selected={expiryDate}
.map((type) => ( onChange={(date) => setExpiryDate(date)}
<option key={type} value={type}> />
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} )}
</option> </>
))} )}
</select> <div className={clsx("flex flex-col gap-4")}>
)} <label className="font-normal text-base text-mti-gray-dim">
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>} Entity
{infos.length !== 0 && ( </label>
<div className="flex w-full flex-col gap-4"> <Select
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span> defaultValue={{
<CodegenTable infos={infos} /> value: (entities || [])[0]?.id,
</div> label: (entities || [])[0]?.label,
)} }}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && ( options={entities.map((e) => ({ value: e.id, label: e.label }))}
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> onChange={(e) => setEntity(e?.value || undefined)}
Generate & Send isClearable={checkAccess(user, ["admin", "developer"])}
</Button> />
)} </div>
</div> <label className="text-mti-gray-dim text-base font-normal">
</> Select the type of user they should be
); </label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS).reduce((acc, x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
acc.push(
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
);
return acc;
}, [] as JSX.Element[])}
</select>
)}
{infos.length > 0 && (
<CodeGenImportSummary
infos={infos}
parsedExcel={parsedExcel}
duplicateRows={duplicatedRows}
/>
)}
{infos.length !== 0 && (
<div className="flex w-full flex-col gap-4">
<span className="text-mti-gray-dim text-base font-normal">
Codes will be sent to:
</span>
<CodegenTable infos={infos} />
</div>
)}
{checkAccess(
user,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"createCodes"
) && (
<Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send
</Button>
)}
</div>
</>
);
} }

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,173 +12,225 @@ 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[] };
} = { } = {
student: { student: {
perm: "createCodeStudent", perm: "createCodeStudent",
list: [], list: [],
}, },
teacher: { teacher: {
perm: "createCodeTeacher", perm: "createCodeTeacher",
list: [], list: [],
}, },
agent: { agent: {
perm: "createCodeCountryManager", perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"], list: ["student", "teacher", "corporate", "mastercorporate"],
}, },
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
list: ["student", "teacher"], list: ["student", "teacher"],
}, },
mastercorporate: { mastercorporate: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "corporate"], list: ["student", "teacher", "corporate"],
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: [
}, "student",
developer: { "teacher",
perm: undefined, "agent",
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], "corporate",
}, "admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
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({
const [generatedCode, setGeneratedCode] = useState<string>(); user,
entities = [],
permissions,
onFinish,
}: Props) {
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()
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); : null
const [type, setType] = useState<Type>("student"); );
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const generateCode = (type: Type) => { const generateCode = (type: Type) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const code = uid.randomUUID(6); const code = uid.randomUUID(6);
axios axios
.post("/api/code", { type, codes: [code], expiryDate, entity }) .post("/api/code", { type, codes: [code], expiryDate, entity })
.then(({ data, status }) => { .then(({ data, status }) => {
if (data.ok) { if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, { toast.success(`Successfully generated a ${capitalize(type)} code!`, {
toastId: "success", toastId: "success",
}); });
setGeneratedCode(code); setGeneratedCode(code);
return; return;
} }
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, { toastId: "forbidden" });
} }
}) })
.catch(({ response: { status, data } }) => { .catch(({ response: { status, data } }) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, { toastId: "forbidden" });
return; return;
} }
toast.error(`Something went wrong, please try again later!`, { toast.error(`Something went wrong, please try again later!`, {
toastId: "error", toastId: "error",
}); });
}); });
}; };
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">
<div className={clsx("flex flex-col gap-4")}> User Code Generator
<label className="font-normal text-base text-mti-gray-dim">Entity</label> </label>
<Select <div className={clsx("flex flex-col gap-4")}>
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }} <label className="font-normal text-base text-mti-gray-dim">
options={entities.map((e) => ({ value: e.id, label: e.label }))} Entity
onChange={(e) => setEntity(e?.value || undefined)} </label>
isClearable={checkAccess(user, ["admin", "developer"])} <Select
/> defaultValue={{
</div> value: (entities || [])[0]?.id,
label: (entities || [])[0]?.label,
}}
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
/>
</div>
<div className={clsx("flex flex-col gap-4")}> <div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Type</label> <label className="font-normal text-base text-mti-gray-dim">Type</label>
<select <select
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
{Object.keys(USER_TYPE_LABELS) >
.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]} </select>
</option> </div>
))}
</select>
</div>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( {checkAccess(user, [
<> "developer",
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> "admin",
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> "corporate",
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> "mastercorporate",
Enabled ]) && (
</Checkbox> <>
</div> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
{isExpiryDateEnabled && ( <label className="text-mti-gray-dim text-base font-normal">
<ReactDatePicker Expiry Date
className={clsx( </label>
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", <Checkbox
"hover:border-mti-purple tooltip", isChecked={isExpiryDateEnabled}
"transition duration-300 ease-in-out", onChange={setIsExpiryDateEnabled}
)} disabled={!!user.subscriptionExpirationDate}
filterDate={(date) => >
moment(date).isAfter(new Date()) && Enabled
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) </Checkbox>
} </div>
dateFormat="dd/MM/yyyy" {isExpiryDateEnabled && (
selected={expiryDate} <ReactDatePicker
onChange={(date) => setExpiryDate(date)} className={clsx(
/> "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
)} "hover:border-mti-purple tooltip",
</> "transition duration-300 ease-in-out"
)} )}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && ( filterDate={(date) =>
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}> moment(date).isAfter(new Date()) &&
Generate (user.subscriptionExpirationDate
</Button> ? moment(date).isBefore(user.subscriptionExpirationDate)
)} : true)
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label> }
<div dateFormat="dd/MM/yyyy"
className={clsx( selected={expiryDate}
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", onChange={(date) => setExpiryDate(date)}
"hover:border-mti-purple tooltip", />
"transition duration-300 ease-in-out", )}
)} </>
data-tip="Click to copy" )}
onClick={() => { {checkAccess(
if (generatedCode) navigator.clipboard.writeText(generatedCode); user,
}}> ["developer", "admin", "corporate", "mastercorporate"],
{generatedCode} permissions,
</div> "createCodes"
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>} ) && (
</div> <Button
); onClick={() => generateCode(type)}
disabled={isExpiryDateEnabled ? !expiryDate : false}
>
Generate
</Button>
)}
<label className="font-normal text-base text-mti-gray-dim">
Generated Code:
</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}
>
{generatedCode}
</div>
{generatedCode && (
<span className="text-sm text-mti-gray-dim font-light">
Give this code to the user to complete their registration
</span>
)}
</div>
);
} }

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

@@ -17,7 +17,7 @@ import {
import axios from "axios"; import axios from "axios";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs"; import { BsPencil, BsTrash, BsUpload } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";

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,23 +308,30 @@ 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 &&
<button viewingAllParticipants !== info.row.original.id && (
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300" <button
onClick={() => setViewingAllParticipants(info.row.original.id)}> className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
, View More onClick={() => setViewingAllParticipants(info.row.original.id)}
</button> >
)} , View More
{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" {info.getValue().length > 5 &&
onClick={() => setViewingAllParticipants(undefined)}> viewingAllParticipants === info.row.original.id && (
, View Less <button
</button> className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
)} onClick={() => setViewingAllParticipants(undefined)}
>
, View Less
</button>
)}
</span> </span>
), ),
}), }),
@@ -252,20 +341,34 @@ 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 &&
<div className="flex gap-2"> (checkAccess(user, ["developer", "admin"]) ||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && ( user.id === row.original.admin) && (
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}> <div className="flex gap-2">
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" /> {(!row.original.disableEditing ||
</div> checkAccess(user, ["developer", "admin"]),
)} "editGroup") && (
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && ( <div
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}> data-tip="Edit"
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" /> className="tooltip cursor-pointer"
</div> onClick={() => setEditingGroup(row.original)}
)} >
</div> <BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
)} </div>
)}
{(!row.original.disableEditing ||
checkAccess(user, ["developer", "admin"]),
"deleteGroup") && (
<div
data-tip="Delete"
className="tooltip cursor-pointer"
onClick={() => deleteGroup(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
</div>
)}
</> </>
); );
}, },
@@ -280,7 +383,11 @@ export default function GroupList({ user }: { user: User }) {
return ( 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,256 +1,341 @@
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",
writing: "text-ielts-writing", writing: "text-ielts-writing",
level: "text-ielts-level", level: "text-ielts-level",
}; };
const columnHelper = createColumnHelper<Package>(); 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 }) => ({
const [duration, setDuration] = useState(pack?.duration || 1); value: currency,
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months"); label,
}));
const [price, setPrice] = useState(pack?.price || 0); function PackageCreator({
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR"); pack,
onClose,
}: {
pack?: Package;
onClose: () => void;
}) {
const [duration, setDuration] = useState(pack?.duration || 1);
const [unit, setUnit] = useState<DurationUnit>(
pack?.duration_unit || "months"
);
const submit = () => { const [price, setPrice] = useState(pack?.price || 0);
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", { const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
duration,
duration_unit: unit,
price,
currency,
})
.then(() => {
toast.success("New payment has been created successfully!");
onClose();
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
});
};
return ( const submit = useCallback(() => {
<div className="flex flex-col gap-8 py-8"> (pack ? axios.patch : axios.post)(
<div className="flex flex-col gap-3"> pack ? `/api/packages/${pack.id}` : "/api/packages",
<label className="font-normal text-base text-mti-gray-dim">Price *</label> {
<div className="flex gap-4 items-center"> duration,
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} /> duration_unit: unit,
price,
currency,
}
)
.then(() => {
toast.success("New payment has been created successfully!");
onClose();
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
});
}, [duration, unit, price, currency, pack, onClose]);
<Select const currencyDefaultValue = useMemo(() => {
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" return {
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))} value: currency || "EUR",
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
onChange={(value) => setCurrency(value?.value || "EUR")} };
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} }, [currency]);
menuPortalTarget={document?.body}
styles={{ return (
menuPortal: (base) => ({...base, zIndex: 9999}), <div className="flex flex-col gap-8 py-8">
control: (styles) => ({ <div className="flex flex-col gap-3">
...styles, <label className="font-normal text-base text-mti-gray-dim">
paddingLeft: "4px", Price *
border: "none", </label>
outline: "none", <div className="flex gap-4 items-center">
":focus": { <Input
outline: "none", defaultValue={price}
}, name="price"
}), type="number"
option: (styles, state) => ({ onChange={(e) => setPrice(parseInt(e))}
...styles, />
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color, <Select
}), className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
}} options={currencyOptions}
/> defaultValue={currencyDefaultValue}
</div> onChange={(value) => setCurrency(value?.value || "EUR")}
</div> value={currencyDefaultValue}
<div className="flex flex-col gap-3"> menuPortalTarget={document?.body}
<label className="font-normal text-base text-mti-gray-dim">Duration *</label> styles={{
<div className="flex gap-4 items-center"> menuPortal: (base) => ({ ...base, zIndex: 9999 }),
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} /> control: (styles) => ({
<Select ...styles,
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" paddingLeft: "4px",
options={[ border: "none",
{value: "days", label: "Days"}, outline: "none",
{value: "weeks", label: "Weeks"}, ":focus": {
{value: "months", label: "Months"}, outline: "none",
{value: "years", label: "Years"}, },
]} }),
defaultValue={{value: "months", label: "Months"}} option: (styles, state) => ({
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")} ...styles,
value={{value: unit, label: capitalize(unit)}} backgroundColor: state.isFocused
menuPortalTarget={document?.body} ? "#D5D9F0"
styles={{ : state.isSelected
menuPortal: (base) => ({...base, zIndex: 9999}), ? "#7872BF"
control: (styles) => ({ : "white",
...styles, color: state.isFocused ? "black" : styles.color,
paddingLeft: "4px", }),
border: "none", }}
outline: "none", />
":focus": { </div>
outline: "none", </div>
}, <div className="flex flex-col gap-3">
}), <label className="font-normal text-base text-mti-gray-dim">
option: (styles, state) => ({ Duration *
...styles, </label>
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", <div className="flex gap-4 items-center">
color: state.isFocused ? "black" : styles.color, <Input
}), defaultValue={duration}
}} name="duration"
/> type="number"
</div> onChange={(e) => setDuration(parseInt(e))}
</div> />
<div className="flex w-full justify-end items-center gap-8 mt-8"> <Select
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}> 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"
Cancel options={[
</Button> { value: "days", label: "Days" },
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}> { value: "weeks", label: "Weeks" },
Submit { value: "months", label: "Months" },
</Button> { value: "years", label: "Years" },
</div> ]}
</div> defaultValue={{ value: "months", label: "Months" }}
); onChange={(value) =>
setUnit((value?.value as DurationUnit) || "months")
}
value={{ value: unit, label: capitalize(unit) }}
menuPortalTarget={document?.body}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<div className="flex w-full justify-end items-center gap-8 mt-8">
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={onClose}
>
Cancel
</Button>
<Button
className="w-full max-w-[200px]"
onClick={submit}
disabled={!duration || !price}
>
Submit
</Button>
</div>
</div>
);
} }
export default function PackageList({user}: {user: User}) { 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(
if (!confirm(`Are you sure you want to delete this package?`)) return; async (pack: Package) => {
if (!confirm(`Are you sure you want to delete this package?`)) return;
axios axios
.delete(`/api/packages/${pack.id}`) .delete(`/api/packages/${pack.id}`)
.then(() => toast.success(`Deleted the "${pack.id}" exam`)) .then(() => toast.success(`Deleted the "${pack.id}" exam`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Package not found!"); toast.error("Package not found!");
return; return;
} }
if (reason.response.status === 403) { if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!"); toast.error("You do not have permission to delete this exam!");
return; return;
} }
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", { () => [
header: "ID", columnHelper.accessor("id", {
cell: (info) => info.getValue(), header: "ID",
}), cell: (info) => info.getValue(),
columnHelper.accessor("duration", { }),
header: "Duration", columnHelper.accessor("duration", {
cell: (info) => ( header: "Duration",
<span> cell: (info) => (
{info.getValue()} {info.row.original.duration_unit} <span>
</span> {info.getValue()} {info.row.original.duration_unit}
), </span>
}), ),
columnHelper.accessor("price", { }),
header: "Price", columnHelper.accessor("price", {
cell: (info) => ( header: "Price",
<span> cell: (info) => (
{info.getValue()} {info.row.original.currency} <span>
</span> {info.getValue()} {info.row.original.currency}
), </span>
}), ),
{ }),
header: "", {
id: "actions", header: "",
cell: ({row}: {row: {original: Package}}) => { id: "actions",
return ( cell: ({ row }: { row: { original: Package } }) => {
<div className="flex gap-4"> return (
{["developer", "admin"].includes(user.type) && ( <div className="flex gap-4">
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}> {["developer", "admin"].includes(user?.type) && (
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <div
</div> data-tip="Edit"
)} className="cursor-pointer tooltip"
{["developer", "admin"].includes(user.type) && ( onClick={() => setEditingPackage(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" /> <BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
</div> {["developer", "admin"].includes(user?.type) && (
); <div
}, data-tip="Delete"
}, className="cursor-pointer tooltip"
]; onClick={() => deletePackage(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
],
[deletePackage, user]
);
const table = useReactTable({ const table = useReactTable({
data: packages, data: packages,
columns: defaultColumns, columns: defaultColumns,
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} /> >
</Modal> <PackageCreator onClose={closeModal} pack={editingPackage} />
<table className="bg-mti-purple-ultralight/40 w-full"> </Modal>
<thead> <table className="bg-mti-purple-ultralight/40 w-full">
{table.getHeaderGroups().map((headerGroup) => ( <thead>
<tr key={headerGroup.id}> {table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => ( <tr key={headerGroup.id}>
<th className="p-4 text-left" key={header.id}> {headerGroup.headers.map((header) => (
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} <th className="p-4 text-left" key={header.id}>
</th> {header.isPlaceholder
))} ? null
</tr> : flexRender(
))} header.column.columnDef.header,
</thead> header.getContext()
<tbody className="px-2"> )}
{table.getRowModel().rows.map((row) => ( </th>
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> ))}
{row.getVisibleCells().map((cell) => ( </tr>
<td className="px-4 py-2" key={cell.id}> ))}
{flexRender(cell.column.columnDef.cell, cell.getContext())} </thead>
</td> <tbody className="px-2">
))} {table.getRowModel().rows.map((row) => (
</tr> <tr
))} className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
</tbody> key={row.id}
</table> >
<button {row.getVisibleCells().map((cell) => (
onClick={() => setIsCreating(true)} <td className="px-4 py-2" key={cell.id}>
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"> {flexRender(cell.column.columnDef.cell, cell.getContext())}
New Package </td>
</button> ))}
</div> </tr>
); ))}
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
>
New Package
</button>
</div>
);
} }

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,

View File

@@ -1,272 +1,375 @@
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";
import {mapBy} from "@/utils";
const USER_TYPE_PERMISSIONS: { const USER_TYPE_PERMISSIONS: {
[key in Type]: {perm: PermissionType | undefined; list: Type[]}; [key in Type]: { perm: PermissionType | undefined; list: Type[] };
} = { } = {
student: { student: {
perm: "createCodeStudent", perm: "createCodeStudent",
list: [], list: [],
}, },
teacher: { teacher: {
perm: "createCodeTeacher", perm: "createCodeTeacher",
list: [], list: [],
}, },
agent: { agent: {
perm: "createCodeCountryManager", perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"], list: ["student", "teacher", "corporate", "mastercorporate"],
}, },
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
list: ["student", "teacher"], list: ["student", "teacher"],
}, },
mastercorporate: { mastercorporate: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "corporate"], list: ["student", "teacher", "corporate"],
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: [
}, "student",
developer: { "teacher",
perm: undefined, "agent",
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], "corporate",
}, "admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
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({
const [name, setName] = useState<string>(); user,
const [email, setEmail] = useState<string>(); users,
const [phone, setPhone] = useState<string>(); entities = [],
const [passportID, setPassportID] = useState<string>(); permissions,
const [studentID, setStudentID] = useState<string>(); onFinish,
const [country, setCountry] = useState(user?.demographicInformation?.country); }: Props) {
const [group, setGroup] = useState<string | null>(); const [name, setName] = useState<string>();
const [password, setPassword] = useState<string>(); const [email, setEmail] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>(); const [phone, setPhone] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [passportID, setPassportID] = useState<string>();
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, const [studentID, setStudentID] = useState<string>();
); const [country, setCountry] = useState(user?.demographicInformation?.country);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [group, setGroup] = useState<string | null>();
const [isLoading, setIsLoading] = useState(false); const [password, setPassword] = useState<string>();
const [type, setType] = useState<Type>("student"); const [confirmPassword, setConfirmPassword] = useState<string>();
const [position, setPosition] = useState<string>(); const [expiryDate, setExpiryDate] = useState<Date | null>(
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined); user?.subscriptionExpirationDate
? moment(user?.subscriptionExpirationDate).toDate()
: null
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [type, setType] = useState<Type>("student");
const [position, setPosition] = useState<string>();
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const {groups} = useEntitiesGroups(); const { groups } = useEntitiesGroups();
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [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);
const body = { const body = {
name, name,
email, email,
password, password,
groupID: group, groupID: group,
entity, entity,
type, type,
studentID: type === "student" ? studentID : undefined, studentID: type === "student" ? studentID : undefined,
expiryDate, expiryDate,
demographicInformation: { demographicInformation: {
passport_id: type === "student" ? passportID : undefined, passport_id: type === "student" ? passportID : undefined,
phone, phone,
country, country,
position, position,
}, },
}; };
axios axios
.post("/api/make_user", body) .post("/api/make_user", body)
.then(() => { .then(() => {
toast.success("That user has been created!"); toast.success("That user has been created!");
onFinish(); onFinish();
setName(""); setName("");
setEmail(""); setEmail("");
setPhone(""); setPhone("");
setPassportID(""); setPassportID("");
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(
setIsExpiryDateEnabled(true); user?.subscriptionExpirationDate
setType("student"); ? moment(user?.subscriptionExpirationDate).toDate()
setPosition(undefined); : null
}) );
.catch((error) => { setIsExpiryDateEnabled(true);
const data = error?.response?.data; setType("student");
if (!!data?.message) return toast.error(data.message); setPosition(undefined);
toast.error("Something went wrong! Please try again later!"); })
}) .catch((error) => {
.finally(() => setIsLoading(false)); const data = error?.response?.data;
}; if (!!data?.message) return toast.error(data.message);
toast.error("Something went wrong! Please try again later!");
})
.finally(() => setIsLoading(false));
};
return ( return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<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
<Input type="password"
type="password" name="password"
name="confirmPassword" label="Password"
label="Confirm Password" value={password}
value={confirmPassword} onChange={setPassword}
onChange={setConfirmPassword} placeholder="Password"
placeholder="ConfirmPassword" required
required />
/> <Input
type="password"
name="confirmPassword"
label="Confirm Password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="ConfirmPassword"
required
/>
<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">
<CountrySelect value={country} onChange={setCountry} /> Country *
</div> </label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required /> <Input
type="tel"
name="phone"
label="Phone number"
value={phone}
onChange={setPhone}
placeholder="Phone number"
required
/>
{type === "student" && ( {type === "student" && (
<> <>
<Input <Input
type="text" type="text"
name="passport_id" name="passport_id"
label="Passport/National ID" label="Passport/National ID"
onChange={setPassportID} onChange={setPassportID}
value={passportID} value={passportID}
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">
<Select Entity
defaultValue={{value: (entities || [])[0]?.id, label: (entities || [])[0]?.label}} </label>
options={entities.map((e) => ({value: e.id, label: e.label}))} <Select
onChange={(e) => setEntity(e?.value || undefined)} defaultValue={{
isClearable={checkAccess(user, ["admin", "developer"])} value: (entities || [])[0]?.id,
/> label: (entities || [])[0]?.label,
</div> }}
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
/>
</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">
<Select Classroom
options={groups.filter((x) => x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))} </label>
onChange={(e) => setGroup(e?.value || undefined)} <Select
isClearable options={groups
/> .filter((x) => x.entity?.id === entity)
</div> .map((g) => ({ value: g.id, label: g.name }))}
onChange={(e) => setGroup(e?.value || undefined)}
isClearable
/>
</div>
<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",
{user && ( "corporate",
<select "mastercorporate",
defaultValue="student" ]) && "col-span-2"
value={type} )}
onChange={(e) => setType(e.target.value as Type)} >
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> <label className="font-normal text-base text-mti-gray-dim">
{Object.keys(USER_TYPE_LABELS) Type
.filter((x) => { </label>
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type]; {user && (
return checkAccess(user, getTypesOfUser(list), permissions, perm); <select
}) defaultValue="student"
.map((type) => ( value={type}
<option key={type} value={type}> onChange={(e) => setType(e.target.value as Type)}
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} 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"
</option> >
))} {Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
</select> const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
)} if (checkAccess(user, getTypesOfUser(list), permissions, perm))
</div> acc.push(x);
return acc;
}, [])}
</select>
)}
</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, [
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> "developer",
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> "admin",
<Checkbox "corporate",
isChecked={isExpiryDateEnabled} "mastercorporate",
onChange={setIsExpiryDateEnabled} ]) && (
disabled={!!user?.subscriptionExpirationDate}> <>
Enabled <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
</Checkbox> <label className="text-mti-gray-dim text-base font-normal">
</div> Expiry Date
{isExpiryDateEnabled && ( </label>
<ReactDatePicker <Checkbox
className={clsx( isChecked={isExpiryDateEnabled}
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", onChange={setIsExpiryDateEnabled}
"hover:border-mti-purple tooltip", disabled={!!user?.subscriptionExpirationDate}
"transition duration-300 ease-in-out", >
)} Enabled
filterDate={(date) => </Checkbox>
moment(date).isAfter(new Date()) && </div>
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) {isExpiryDateEnabled && (
} <ReactDatePicker
dateFormat="dd/MM/yyyy" className={clsx(
selected={expiryDate} "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
onChange={(date) => setExpiryDate(date)} "hover:border-mti-purple tooltip",
/> "transition duration-300 ease-in-out"
)} )}
</> filterDate={(date) =>
)} moment(date).isAfter(new Date()) &&
</div> (user?.subscriptionExpirationDate
</div> ? moment(date).isBefore(
user?.subscriptionExpirationDate
)
: true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
</div>
</div>
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}> <Button
Create User onClick={createUser}
</Button> isLoading={isLoading}
</div> disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
); >
Create User
</Button>
</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

@@ -13,267 +13,268 @@ import moment from "moment";
import useAcceptedTerms from "@/hooks/useAcceptedTerms"; import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props { interface Props {
isLoading: boolean; isLoading: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>; mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification; sendEmailVerification: typeof sendEmailVerification;
} }
const availableDurations = { const availableDurations = {
"1_month": { label: "1 Month", number: 1 }, "1_month": { label: "1 Month", number: 1 },
"3_months": { label: "3 Months", number: 3 }, "3_months": { label: "3 Months", number: 3 },
"6_months": { label: "6 Months", number: 6 }, "6_months": { label: "6 Months", number: 6 },
"12_months": { label: "12 Months", number: 12 }, "12_months": { label: "12 Months", number: 12 },
}; };
export default function RegisterCorporate({ export default function RegisterCorporate({
isLoading, isLoading,
setIsLoading, setIsLoading,
mutateUser, mutateUser,
sendEmailVerification, sendEmailVerification,
}: Props) { }: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [referralAgent, setReferralAgent] = useState<string | undefined>(); const [referralAgent, setReferralAgent] = useState<string | undefined>();
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0); const [companyUsers, setCompanyUsers] = useState(0);
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) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please logout and re-login.", { toast.error("Something went wrong, please logout and re-login.", {
toastId: "send-verify-error", toastId: "send-verify-error",
}); });
}; };
const register = (e: any) => { const register = (e: any) => {
e.preventDefault(); e.preventDefault();
if (confirmPassword !== password) { if (confirmPassword !== password) {
toast.error("Your passwords do not match!", { toast.error("Your passwords do not match!", {
toastId: "password-not-match", toastId: "password-not-match",
}); });
return; return;
} }
setIsLoading(true); setIsLoading(true);
axios axios
.post("/api/register", { .post("/api/register", {
name, name,
email, email,
password, password,
type: "corporate", type: "corporate",
profilePicture: "/defaultAvatar.png", profilePicture: "/defaultAvatar.png",
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(), subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
corporateInformation: { corporateInformation: {
monthlyDuration: subscriptionDuration, monthlyDuration: subscriptionDuration,
referralAgent, referralAgent,
}, },
}) })
.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) => {
console.log(error.response.data); console.log(error.response.data);
if (error.response.status === 401) { if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!"); toast.error("There is already a user with that e-mail!");
return; return;
} }
if (error.response.status === 400) { if (error.response.status === 400) {
toast.error("The provided code is invalid!"); toast.error("The provided code is invalid!");
return; return;
} }
toast.error("There was something wrong, please try again!"); toast.error("There was something wrong, please try again!");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
return ( return (
<form <form
className="flex w-full flex-col items-center gap-4" className="flex w-full flex-col items-center gap-4"
onSubmit={register} onSubmit={register}
> >
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<Input <Input
type="text" type="text"
name="name" name="name"
onChange={(e) => setName(e)} onChange={(e) => setName(e)}
placeholder="Enter your name" placeholder="Enter your name"
defaultValue={name} defaultValue={name}
required required
/> />
<Input <Input
type="email" type="email"
name="email" name="email"
onChange={(e) => setEmail(e.toLowerCase())} onChange={(e) => setEmail(e.toLowerCase())}
placeholder="Enter email address" placeholder="Enter email address"
defaultValue={email} defaultValue={email}
required required
/> />
</div> </div>
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<Input <Input
type="password" type="password"
name="password" name="password"
onChange={(e) => setPassword(e)} onChange={(e) => setPassword(e)}
placeholder="Enter your password" placeholder="Enter your password"
defaultValue={password} defaultValue={password}
required required
/> />
<Input <Input
type="password" type="password"
name="confirmPassword" name="confirmPassword"
onChange={(e) => setConfirmPassword(e)} onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password" placeholder="Confirm your password"
defaultValue={confirmPassword} defaultValue={confirmPassword}
required required
/> />
</div> </div>
<Divider className="!my-2 w-full" /> <Divider className="!my-2 w-full" />
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<Input <Input
type="text" type="text"
name="companyName" name="companyName"
onChange={(e) => setCompanyName(e)} onChange={(e) => setCompanyName(e)}
placeholder="Corporate name" placeholder="Corporate name"
label="Corporate name" label="Corporate name"
defaultValue={companyName} defaultValue={companyName}
required required
/> />
<Input <Input
type="number" type="number"
name="companyUsers" name="companyUsers"
onChange={(e) => setCompanyUsers(parseInt(e))} onChange={(e) => setCompanyUsers(parseInt(e))}
label="Number of users" label="Number of users"
defaultValue={companyUsers} defaultValue={companyUsers}
required required
/> />
</div> </div>
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">
Referral * Referral *
</label> </label>
<Select <Select
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed" 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" }} ]}
onChange={(value) => setReferralAgent(value?.value)} defaultValue={{ value: "", label: "No referral" }}
styles={{ onChange={(value) => setReferralAgent(value?.value)}
control: (styles) => ({ styles={{
...styles, control: (styles) => ({
paddingLeft: "4px", ...styles,
border: "none", paddingLeft: "4px",
outline: "none", border: "none",
":focus": { outline: "none",
outline: "none", ":focus": {
}, outline: "none",
}), },
option: (styles, state) => ({ }),
...styles, option: (styles, state) => ({
backgroundColor: state.isFocused ...styles,
? "#D5D9F0" backgroundColor: state.isFocused
: state.isSelected ? "#D5D9F0"
? "#7872BF" : state.isSelected
: "white", ? "#7872BF"
color: state.isFocused ? "black" : styles.color, : "white",
}), color: state.isFocused ? "black" : styles.color,
}} }),
/> }}
</div> />
</div>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">
Subscription Duration * Subscription Duration *
</label> </label>
<Select <Select
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed" className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
options={Object.keys(availableDurations).map((value) => ({ options={Object.keys(availableDurations).map((value) => ({
value, value,
label: label:
availableDurations[value as keyof typeof availableDurations] availableDurations[value as keyof typeof availableDurations]
.label, .label,
}))} }))}
defaultValue={{ defaultValue={{
value: "1_month", value: "1_month",
label: availableDurations["1_month"].label, label: availableDurations["1_month"].label,
}} }}
onChange={(value) => onChange={(value) =>
setSubscriptionDuration( setSubscriptionDuration(
value value
? availableDurations[ ? availableDurations[
value.value as keyof typeof availableDurations value.value as keyof typeof availableDurations
].number ].number
: 1, : 1
) )
} }
styles={{ styles={{
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
border: "none", border: "none",
outline: "none", outline: "none",
":focus": { ":focus": {
outline: "none", outline: "none",
}, },
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused
? "#D5D9F0" ? "#D5D9F0"
: state.isSelected : state.isSelected
? "#7872BF" ? "#7872BF"
: "white", : "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
/> />
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-start gap-4"> <div className="flex w-full flex-col items-start gap-4">
{renderCheckbox()} {renderCheckbox()}
</div> </div>
<Button <Button
className="w-full lg:mt-8" className="w-full lg:mt-8"
color="purple" color="purple"
disabled={ disabled={
isLoading || isLoading ||
!email || !email ||
!name || !name ||
!password || !password ||
!confirmPassword || !confirmPassword ||
password !== confirmPassword || password !== confirmPassword ||
!companyName || !companyName ||
companyUsers <= 0 companyUsers <= 0
} }
> >
Create account Create account
</Button> </Button>
</form> </form>
); );
} }

View File

@@ -23,5 +23,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const entityIdsArray = entityIdsString.split(","); const entityIdsArray = entityIdsString.split(",");
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray)); 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

@@ -1,6 +1,6 @@
// 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 { createApprovalWorkflowOnExamCreation } 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";
@@ -10,6 +10,7 @@ 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 { 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";
@@ -18,140 +19,161 @@ const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { // Temporary: Adding UUID here but later move to backend.
if (req.method === "GET") return await GET(req, res); function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
if (req.method === "POST") return await POST(req, res); const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
res.status(404).json({ ok: false }); exam.parts = exam.parts.map((part) => {
const updatedExercises = part.exercises.map((exercise: any) => {
arraysToUpdate.forEach((arrayName) => {
if (exercise[arrayName] && Array.isArray(exercise[arrayName])) {
exercise[arrayName] = exercise[arrayName].map((item: any) => (item.uuid ? item : { ...item, uuid: uuidv4() }));
}
});
return exercise;
});
return { ...part, exercises: updatedExercises };
});
return exam;
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await GET(req, res);
if (req.method === "POST") return await POST(req, res);
res.status(404).json({ ok: false });
} }
async function GET(req: NextApiRequest, res: NextApiResponse) { 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 { module, avoidRepeated, variant, instructorGender } = req.query as { const { module, avoidRepeated, variant, instructorGender } = req.query as {
module: Module; module: Module;
avoidRepeated: string; avoidRepeated: string;
variant?: Variant; variant?: Variant;
instructorGender?: InstructorGender; instructorGender?: InstructorGender;
}; };
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender); const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
res.status(200).json(exams); res.status(200).json(exams);
} }
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
const { module } = req.query as { module: string }; const { module } = req.query as { module: string };
const session = client.startSession(); const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
try { try {
const exam = { let exam = {
access: "public", // default access is public access: "public", // default access is public
...req.body, ...req.body,
module: module, module: module,
entities, entities,
createdBy: user.id, createdBy: user.id,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
let responseStatus: number; // Temporary: Adding UUID here but later move to backend.
let responseMessage: string; exam = addUUIDs(exam);
await session.withTransaction(async () => { let responseStatus: number;
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session }); let responseMessage: string;
// Check whether the id of the exam matches another exam with different await session.withTransaction(async () => {
// owners, throw exception if there is, else allow editing const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
const existingExamOwners = docSnap?.owners ?? [];
const newExamOwners = exam.owners ?? [];
const ownersSet = new Set(existingExamOwners); // Check whether the id of the exam matches another exam with different
// owners, throw exception if there is, else allow editing
const existingExamOwners = docSnap?.owners ?? [];
const newExamOwners = exam.owners ?? [];
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) { const ownersSet = new Set(existingExamOwners);
throw new Error("Name already exists");
}
if (exam.requiresApproval === true) { if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
exam.access = "confidential"; throw new Error("Name already exists");
} }
await db.collection(module).updateOne( if (exam.requiresApproval === true) {
{ id: req.body.id }, exam.access = "confidential";
{ $set: { id: req.body.id, ...exam } }, }
{
upsert: true,
session,
}
);
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response. await db.collection(module).updateOne(
responseStatus = 200; { id: req.body.id },
responseMessage = `Successfully updated exam with ID: "${exam.id}"`; { $set: { id: req.body.id, ...exam } },
{
upsert: true,
session,
}
);
// create workflow only if exam is being created for the first time // 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 (docSnap === null) { responseStatus = 200;
try { responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
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) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
} else if (successCount > 0) {
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`;
} else {
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`;
}
}
} catch (error) {
console.error("Workflow creation error:", error);
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
}
} else {
// if exam was updated, log the updates
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
if (approvalWorkflows) { // create workflow only if exam is being created for the first time
const differences = generateExamDifferences(docSnap as Exam, exam as Exam); if (docSnap === null) {
if (differences) { try {
approvalWorkflows.forEach((workflow) => { if (exam.requiresApproval === false) {
const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected); responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`;
} else if (isAdmin(user)) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`;
} else {
const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
if (workflow.steps[currentStepIndex].examChanges === undefined) { if (successCount === totalCount) {
workflow.steps[currentStepIndex].examChanges = [...differences]; responseStatus = 200;
} else { responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
workflow.steps[currentStepIndex].examChanges!.push(...differences); } else if (successCount > 0) {
} responseStatus = 207;
}); responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`;
await updateApprovalWorkflows("active-workflows", approvalWorkflows); } else {
} responseStatus = 207;
} responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`;
} }
}
} catch (error) {
console.error("Workflow creation error:", error);
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
}
} else {
// if exam was updated, log the updates
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
res.status(responseStatus).json({ if (approvalWorkflows) {
message: responseMessage, const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
}); if (differences) {
}); approvalWorkflows.forEach((workflow) => {
} catch (error) { const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected);
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message }); if (workflow.steps[currentStepIndex].examChanges === undefined) {
} finally { workflow.steps[currentStepIndex].examChanges = [...differences];
session.endSession(); } else {
} workflow.steps[currentStepIndex].examChanges!.push(...differences);
}
});
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
}
}
}
res.status(responseStatus).json({
message: responseMessage,
});
});
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });
} finally {
session.endSession();
}
} }

View File

@@ -3,7 +3,7 @@ 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, map } from "lodash"; import { flatten } from "lodash";
import { AccessType, 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 { requestUser } from "../../../utils/api";

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

@@ -150,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 change the access type of the exam from confidential to private.`)) return; if (!confirm(`Are you sure you want to approve the last step and complete the approval process?`)) return;
} }
const updatedWorkflow: ApprovalWorkflow = { const updatedWorkflow: ApprovalWorkflow = {
@@ -192,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}`, { access: "private" }) .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) {
@@ -260,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;
} }
@@ -389,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"
@@ -554,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="whitespace-pre-wrap text-sm text-gray-500 mb-2"> <>
{change} <p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
</p> <span className="text-mti-purple-light text-lg">{change.charAt(0)}</span>
{change.slice(1)}
</p>
<hr className="my-3 h-[3px] bg-mti-purple-light rounded-full w-full" />
</>
)) ))
) : ( ) : (
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p> <p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
@@ -576,7 +577,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
value={comments} 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

@@ -63,26 +63,26 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const [users, groups] = await Promise.all([ const [users, groups] = await Promise.all([
isAdmin(user) isAdmin(user)
? getUsers( ? getUsers(
{}, {},
0, 0,
{}, {},
{ {
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}
)
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0, _id: 0,
id: 1, id: 1,
type: 1, type: 1,
name: 1, name: 1,
email: 1, email: 1,
levels: 1, levels: 1,
}), }
)
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}),
isAdmin(user) isAdmin(user)
? getGroups() ? getGroups()
: getGroupsByEntities(mapBy(allowedEntities, "id")), : getGroupsByEntities(mapBy(allowedEntities, "id")),
@@ -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();
@@ -326,7 +329,7 @@ export default function AssignmentsPage({
onClick={ onClick={
(!selectedModules.includes("level") && (!selectedModules.includes("level") &&
selectedModules.length === 0) || selectedModules.length === 0) ||
selectedModules.includes("level") selectedModules.includes("level")
? () => toggleModule("level") ? () => toggleModule("level")
: undefined : undefined
} }
@@ -501,37 +504,64 @@ export default function AssignmentsPage({
Random Exams Random Exams
</Checkbox> </Checkbox>
{!useRandomExams && ( {!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4"> <>
{selectedModules.map((module) => ( <Checkbox
<div key={module} className="flex flex-col gap-3 w-full"> isChecked={showApprovedExams}
<label className="font-normal text-base text-mti-gray-dim"> onChange={() => {
{capitalize(module)} Exam setShowApprovedExams((prev) => !prev)
</label> }}
<Select >
value={{ Show approved exams
value: </Checkbox>
examIDs.find((e) => e.module === module)?.id || <Checkbox
null, isChecked={showNonApprovedExams}
label: onChange={() => {
examIDs.find((e) => e.module === module)?.id || "", setShowNonApprovedExams((prev) => !prev)
}} }}
onChange={(value) => >
value Show non-approved exams
? setExamIDs((prev) => [ </Checkbox>
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select
isClearable
value={{
value:
examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [
...prev.filter((x) => x.module !== module), ...prev.filter((x) => x.module !== module),
{ id: value.value!, module }, { id: value.value!, module },
]) ])
: setExamIDs((prev) => : setExamIDs((prev) =>
prev.filter((x) => x.module !== module) prev.filter((x) => x.module !== module)
) )
} }
options={exams options={exams
.filter((x) => !x.isDiagnostic && x.module === module) .filter((x) =>
.map((x) => ({ value: x.id, label: x.id }))} !x.isDiagnostic &&
/> x.module === module &&
</div> x.access !== "confidential" &&
))} (
</div> (x.requiresApproval && showApprovedExams) ||
(!x.requiresApproval && showNonApprovedExams)
)
)
.map((x) => ({ value: x.id, label: x.id }))}
/>
</div>
))}
</div>
</>
)} )}
</div> </div>
)} )}
@@ -568,7 +598,7 @@ export default function AssignmentsPage({
users users
.filter((u) => g.participants.includes(u.id)) .filter((u) => g.participants.includes(u.id))
.every((u) => assignees.includes(u.id)) && .every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white" "!bg-mti-purple-light !text-white"
)} )}
> >
{g.name} {g.name}
@@ -653,7 +683,7 @@ export default function AssignmentsPage({
users users
.filter((u) => g.participants.includes(u.id)) .filter((u) => g.participants.includes(u.id))
.every((u) => teachers.includes(u.id)) && .every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white" "!bg-mti-purple-light !text-white"
)} )}
> >
{g.name} {g.name}

View File

@@ -1,250 +1,325 @@
/* 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(
const user = await requestUser(req, res); async ({ req, res, query }) => {
if (!user) return redirect("/login"); const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/"); if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, "id"); const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
const permissions: Permission = { const entities = await getEntitiesWithRoles(
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0, isAdmin(user) ? undefined : entityIDs
listening: findAllowedEntities(user, entities, `generate_listening`).length > 0, );
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0,
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
level: findAllowedEntities(user, entities, `generate_level`).length > 0,
};
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy"); const generatePermissions = groupAllowedEntitiesByPermissions(
console.log(entitiesAllowEditPrivacy); user,
entities,
[
"generate_reading",
"generate_listening",
"generate_writing",
"generate_speaking",
"generate_level",
]
);
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/"); const permissions: Permission = {
reading: generatePermissions["generate_reading"].length > 0,
listening: generatePermissions["generate_listening"].length > 0,
writing: generatePermissions["generate_writing"].length > 0,
speaking: generatePermissions["generate_speaking"].length > 0,
level: generatePermissions["generate_level"].length > 0,
};
const {id, module: examModule} = query as {id?: string; module?: Module}; const {
if (!id || !examModule) return {props: serialize({user, permissions})}; ["update_exam_privacy"]: entitiesAllowEditPrivacy,
["create_confidential_exams"]: entitiesAllowConfExams,
["create_public_exams"]: entitiesAllowPublicExams,
} = groupAllowedEntitiesByPermissions(user, entities, [
"update_exam_privacy",
"create_confidential_exams",
"create_public_exams",
]);
//if (!permissions[module]) return redirect("/generation") if (Object.keys(permissions).every((p) => !permissions[p as Module]))
return redirect("/");
const exam = await getExam(examModule, id); const { id, module: examModule } = query as {
if (!exam) return redirect("/generation"); id?: string;
module?: Module;
};
if (!id || !examModule) return { props: serialize({ user, permissions }) };
return { //if (!permissions[module]) return redirect("/generation")
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
}; const exam = await getExam(examModule, id);
}, sessionOptions); if (!exam) return redirect("/generation");
return {
props: serialize({
id,
user,
exam,
examModule,
permissions,
entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}),
};
},
sessionOptions
);
export default function Generation({ export default function Generation({
id, id,
user, user,
exam, exam,
examModule, examModule,
permissions, permissions,
entitiesAllowEditPrivacy, entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}: { }: {
id: string; id: string;
user: User; user: User;
exam?: Exam; exam?: Exam;
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(() => {
if (id && exam && examModule) { if (id && exam && examModule) {
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]);
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();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// media cleanup on unmount // media cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
const state = modules; const state = modules;
if (state.writing.academic_url) { if (state.writing.academic_url) {
URL.revokeObjectURL(state.writing.academic_url); URL.revokeObjectURL(state.writing.academic_url);
} }
state.listening.sections.forEach((section) => { state.listening.sections.forEach((section) => {
const listeningPart = section.state as ListeningPart; const listeningPart = section.state as ListeningPart;
if (listeningPart.audio?.source) { if (listeningPart.audio?.source) {
URL.revokeObjectURL(listeningPart.audio.source); URL.revokeObjectURL(listeningPart.audio.source);
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
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) => {
const sectionState = section.state as Exercise; const sectionState = section.state as Exercise;
if (sectionState.type === "speaking") { if (sectionState.type === "speaking") {
const speakingExercise = sectionState as SpeakingExercise; const speakingExercise = sectionState as SpeakingExercise;
URL.revokeObjectURL(speakingExercise.video_url); URL.revokeObjectURL(speakingExercise.video_url);
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
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 =
interactiveSpeaking.prompts.forEach((prompt) => { sectionState as InteractiveSpeakingExercise;
URL.revokeObjectURL(prompt.video_url); interactiveSpeaking.prompts.forEach((prompt) => {
}); URL.revokeObjectURL(prompt.video_url);
dispatch({ });
type: "UPDATE_SECTION_SINGLE_FIELD", dispatch({
payload: { type: "UPDATE_SECTION_SINGLE_FIELD",
sectionId: section.sectionId, payload: {
module: "listening", sectionId: section.sectionId,
field: "state", module: "listening",
value: { field: "state",
...interactiveSpeaking, value: {
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})), ...interactiveSpeaking,
}, prompts: interactiveSpeaking.prompts.map((p) => ({
}, ...p,
}); video_url: undefined,
} })),
}); },
dispatch({type: "FULL_RESET"}); },
}; });
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, []); });
dispatch({ type: "FULL_RESET" });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return ( return (
<> <>
<Head> <Head>
<title>Exam Generation | EnCoach</title> <title>Exam Generation | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<> <>
<h1 className="text-2xl font-semibold">Exam Editor</h1> <h1 className="text-2xl font-semibold">Exam Editor</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input <Input
type="text" type="text"
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">
<RadioGroup Module
value={currentModule} </label>
onChange={(currentModule) => updateRoot({currentModule})} <RadioGroup
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"> value={currentModule}
{[...MODULE_ARRAY] onChange={(currentModule) => updateRoot({ currentModule })}
.filter((m) => permissions[m]) className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
.map((x) => ( >
<Radio value={x} key={x}> {[...MODULE_ARRAY].reduce((acc, x) => {
{({checked}) => ( if (permissions[x])
<span acc.push(
className={clsx( <Radio value={x} key={x}>
"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", {({ checked }) => (
"transition duration-300 ease-in-out", <span
x === "reading" && className={clsx(
(!checked "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",
? "bg-white border-mti-gray-platinum" "transition duration-300 ease-in-out",
: "bg-ielts-reading/70 border-ielts-reading text-white"), x === "reading" &&
x === "listening" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white"),
: "bg-ielts-listening/70 border-ielts-listening text-white"), x === "listening" &&
x === "writing" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-listening/70 border-ielts-listening text-white"),
: "bg-ielts-writing/70 border-ielts-writing text-white"), x === "writing" &&
x === "speaking" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-writing/70 border-ielts-writing text-white"),
: "bg-ielts-speaking/70 border-ielts-speaking text-white"), x === "speaking" &&
x === "level" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-speaking/70 border-ielts-speaking text-white"),
: "bg-ielts-level/70 border-ielts-level text-white"), x === "level" &&
)}> (!checked
{capitalize(x)} ? "bg-white border-mti-gray-platinum"
</span> : "bg-ielts-level/70 border-ielts-level text-white")
)} )}
</Radio> >
))} {capitalize(x)}
</RadioGroup> </span>
</div> )}
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} /> </Radio>
</> );
)} return acc;
</> }, [] as JSX.Element[])}
); </RadioGroup>
</div>
<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

@@ -203,6 +203,32 @@ const Training: React.FC<{
</Head> </Head>
<ToastContainer /> <ToastContainer />
<RecordFilter
entities={entities}
user={user}
isAdmin={isAdmin}
filterState={{ filter: filter, setFilter: setFilter }}
assignments={false}
>
{user.type === "student" && (
<>
<div className="flex items-center">
<div className="font-semibold text-2xl">
Generate New Training Material
</div>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
"transition duration-300 ease-in-out"
)}
onClick={handleNewTrainingContent}
>
<FaPlus />
</button>
</div>
</>
)}
</RecordFilter>
<> <>
{isNewContentLoading || areRecordsLoading ? ( {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"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
@@ -215,38 +241,10 @@ const Training: React.FC<{
</div> </div>
) : ( ) : (
<> <>
<RecordFilter
entities={entities}
user={user}
isAdmin={isAdmin}
filterState={{ filter: filter, setFilter: setFilter }}
assignments={false}
>
{user.type === "student" && (
<>
<div className="flex items-center">
<div className="font-semibold text-2xl">
Generate New Training Material
</div>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
"transition duration-300 ease-in-out"
)}
onClick={handleNewTrainingContent}
>
<FaPlus />
</button>
</div>
</>
)}
</RecordFilter>
{trainingContent.length == 0 && ( {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

@@ -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;
@@ -130,7 +130,7 @@ export const rootReducer = (
if (state.flags.reviewAll) { if (state.flags.reviewAll) {
const notLastModule = state.moduleIndex < state.selectedModules.length - 1; const notLastModule = state.moduleIndex < state.selectedModules.length - 1;
const moduleIndex = notLastModule ? state.moduleIndex + 1 : -1; const moduleIndex = notLastModule ? state.moduleIndex + 1 : -1;
if (notLastModule) { if (notLastModule) {
return { return {
questionIndex: 0, questionIndex: 0,
@@ -152,7 +152,7 @@ export const rootReducer = (
moduleIndex: -1 moduleIndex: -1
} }
} }
} }
case 'UPDATE_EXAMS': { case 'UPDATE_EXAMS': {
const exams = state.exams.map((e) => updateExamWithUserSolutions(e, state.userSolutions)); const exams = state.exams.map((e) => updateExamWithUserSolutions(e, state.userSolutions));

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

@@ -4,7 +4,7 @@ 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, entityIds?: string[], ids?: string[]) => { export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[], assignee?: string) => {
const filters: any = {}; const filters: any = {};
if (ids && ids.length > 0) { if (ids && ids.length > 0) {
@@ -15,7 +15,15 @@ export const getApprovalWorkflows = async (collection: string, entityIds?: strin
filters.entityId = { $in: entityIds }; filters.entityId = { $in: entityIds };
} }
return await db.collection<ApprovalWorkflow>(collection).find(filters).toArray(); if (assignee) {
filters["steps.assignees"] = assignee;
}
return await db
.collection<ApprovalWorkflow>(collection)
.find(filters)
.sort({ startDate: -1 })
.toArray();
}; };
export const getApprovalWorkflow = async (collection: string, id: string) => { export const getApprovalWorkflow = async (collection: string, id: string) => {
@@ -26,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();
}; };

View File

@@ -1,7 +1,6 @@
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { diff, Diff } from "deep-diff";
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]); const EXCLUDED_KEYS = new Set<string>(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]);
const PATH_LABELS: Record<string, string> = { const PATH_LABELS: Record<string, string> = {
access: "Access Type", access: "Access Type",
@@ -24,124 +23,146 @@ const PATH_LABELS: Record<string, string> = {
allowRepetition: "Allow Repetition", allowRepetition: "Allow Repetition",
maxWords: "Max Words", maxWords: "Max Words",
minTimer: "Timer", 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; return val !== null && typeof val === "object" && !Array.isArray(val);
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return;
}
const pathString = pathToHumanReadable(change.path);
switch (change.kind) {
case "N": // New property/element
return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`;
case "D": // Deleted property/element
return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`;
case "E": // Edited property/element
return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`;
case "A": // Array change
return formatArrayChange(change);
default:
return;
}
} }
function formatArrayChange(change: Diff<any, any>): string | undefined { function formatPrimitive(value: any): string {
if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return;
}
const pathString = pathToHumanReadable(change.path);
const arrayChange = (change as any).item;
const idx = (change as any).index;
if (!arrayChange) return;
switch (arrayChange.kind) {
case "N":
return `• Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`;
case "D":
return `• Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`;
case "E":
return `• Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`;
case "A":
return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`;
default:
return;
}
}
function formatValue(value: any): string {
if (value === null) return "null";
if (value === undefined) return "undefined"; if (value === undefined) return "undefined";
if (value === null) return "null";
if (typeof value === "object") {
try {
const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS);
const renamed = renameKeysDeep(sanitized, PATH_LABELS);
return JSON.stringify(renamed, null, 2);
} catch {
return String(value);
}
}
return JSON.stringify(value); return JSON.stringify(value);
} }
function removeExcludedKeysDeep(obj: any, excludedKeys: Set<string>): any {
if (Array.isArray(obj)) {
return obj.map((item) => removeExcludedKeysDeep(item, excludedKeys));
} else if (obj && typeof obj === "object") {
const newObj: any = {};
for (const key of Object.keys(obj)) {
if (excludedKeys.has(key)) {
// Skip this key entirely
continue;
}
newObj[key] = removeExcludedKeysDeep(obj[key], excludedKeys);
}
return newObj;
}
return obj;
}
function renameKeysDeep(obj: any, renameMap: Record<string, string>): any {
if (Array.isArray(obj)) {
return obj.map((item) => renameKeysDeep(item, renameMap));
} else if (obj && typeof obj === "object") {
const newObj: any = {};
for (const key of Object.keys(obj)) {
const newKey = renameMap[key] ?? key; // Use friendly label if available
newObj[newKey] = renameKeysDeep(obj[key], renameMap);
}
return newObj;
}
return obj;
}
/**
* Convert an array of path segments into a user-friendly string.
* e.g. ["parts", 0, "exercises", 1, "prompt"]
* → "Parts → [#1] → Exercises → [#2] → Prompt"
*/
function pathToHumanReadable(pathSegments: Array<string | number>): string { function pathToHumanReadable(pathSegments: Array<string | number>): string {
return pathSegments const mapped = pathSegments.map((seg) => {
.map((seg) => { if (typeof seg === "number") {
if (typeof seg === "number") { return `#${seg + 1}`;
return `[#${seg + 1}]`; }
} return PATH_LABELS[seg] ?? seg;
return PATH_LABELS[seg] ?? seg; });
})
.join(" → "); let result = "";
for (let i = 0; i < mapped.length; i++) {
result += mapped[i];
if (mapped[i].startsWith("#") && i < mapped.length - 1) {
result += " - ";
} else if (i < mapped.length - 1) {
result += " ";
}
}
return result.trim();
}
function getArrayItemLabel(path: (string | number)[]): string {
if (path.length === 0) return "item";
const lastSegment = path[path.length - 1];
if (typeof lastSegment === "string" && ARRAY_ITEM_LABELS[lastSegment]) {
return ARRAY_ITEM_LABELS[lastSegment];
}
return "item";
}
function getIdentifier(item: any): string | number | undefined {
if (item?.uuid !== undefined) return item.uuid;
if (item?.id !== undefined) return item.id;
return undefined;
}
function compareObjects(oldObj: any, newObj: any, path: (string | number)[], differences: string[]) {
if (Array.isArray(oldObj) && Array.isArray(newObj)) {
// Check if array elements are objects with an identifier (uuid or id).
if (oldObj.length > 0 && typeof oldObj[0] === "object" && getIdentifier(oldObj[0]) !== undefined) {
// Process removed items
const newIds = new Set(newObj.map((item: any) => getIdentifier(item)));
for (let i = 0; i < oldObj.length; i++) {
const oldItem = oldObj[i];
const identifier = getIdentifier(oldItem);
if (identifier !== undefined && !newIds.has(identifier)) {
differences.push(`• Removed ${getArrayItemLabel(path)} #${i + 1} from ${pathToHumanReadable(path)}\n`);
}
}
const oldIndexMap = new Map(oldObj.map((item: any, index: number) => [getIdentifier(item), index]));
// Process items in the new array using their order.
for (let i = 0; i < newObj.length; i++) {
const newItem = newObj[i];
const identifier = getIdentifier(newItem);
if (identifier !== undefined) {
if (oldIndexMap.has(identifier)) {
const oldIndex = oldIndexMap.get(identifier)!;
const oldItem = oldObj[oldIndex];
compareObjects(oldItem, newItem, path.concat(`#${i + 1}`), differences);
} else {
differences.push(`• Added new ${getArrayItemLabel(path)} #${i + 1} at ${pathToHumanReadable(path)}\n`);
}
} else {
// Fallback: if item does not have an identifier, compare by index.
compareObjects(oldObj[i], newItem, path.concat(`#${i + 1}`), differences);
}
}
} else {
// For arrays that are not identifier-based, compare element by element.
const maxLength = Math.max(oldObj.length, newObj.length);
for (let i = 0; i < maxLength; i++) {
compareObjects(oldObj[i], newObj[i], path.concat(`#${i + 1}`), differences);
}
}
} else if (isObject(oldObj) && isObject(newObj)) {
// Compare objects by keys (ignoring excluded keys).
const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
for (const key of keys) {
if (EXCLUDED_KEYS.has(key)) continue;
compareObjects(oldObj[key], newObj[key], path.concat(key), differences);
}
} else {
if (oldObj !== newObj) {
differences.push(`• Changed ${pathToHumanReadable(path)} from:\n ${formatPrimitive(oldObj)}\n To:\n ${formatPrimitive(newObj)}\n`);
}
}
} }

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