@@ -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
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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: ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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: [""]
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -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: [""]
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
(sectionId: number) => {
|
||||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||||
toast.error("Include at least one section!");
|
toast.error("Include at least one section!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
||||||
};
|
},
|
||||||
|
[dispatch, expandedSections, sectionIds]
|
||||||
|
);
|
||||||
|
|
||||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
const Settings = useMemo(
|
||||||
reading: ReadingSettings,
|
() => ModuleSettings[currentModule],
|
||||||
writing: WritingSettings,
|
[currentModule]
|
||||||
speaking: SpeakingSettings,
|
);
|
||||||
listening: ListeningSettings,
|
|
||||||
level: LevelSettings,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Settings = ModuleSettings[currentModule];
|
const showImport = useMemo(
|
||||||
const showImport =
|
() =>
|
||||||
importModule && ["reading", "listening", "level"].includes(currentModule);
|
importModule && ["reading", "listening", "level"].includes(currentModule),
|
||||||
|
[importModule, currentModule]
|
||||||
|
);
|
||||||
|
|
||||||
const updateLevelParts = (parts: number) => {
|
const accessTypeOptions = useMemo(() => {
|
||||||
|
let options: Option[] = [{ value: "private", label: "Private" }];
|
||||||
|
if (entitiesAllowConfExams.length > 0) {
|
||||||
|
options.push({ value: "confidential", label: "Confidential" });
|
||||||
|
}
|
||||||
|
if (entitiesAllowPublicExams.length > 0) {
|
||||||
|
options.push({ value: "public", label: "Public" });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
|
||||||
|
|
||||||
|
const updateLevelParts = useCallback((parts: number) => {
|
||||||
setNumberOfLevelParts(parts);
|
setNumberOfLevelParts(parts);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -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,14 +286,14 @@ 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"
|
||||||
options={ACCESSTYPE.map((item) => ({
|
disabled={
|
||||||
value: item,
|
accessTypeOptions.length === 0 ||
|
||||||
label: capitalize(item),
|
entitiesAllowEditPrivacy.length === 0
|
||||||
}))}
|
}
|
||||||
|
options={accessTypeOptions}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (value?.value) {
|
if (value?.value) {
|
||||||
updateModule({ access: value.value! as AccessType });
|
updateModule({ access: value.value! as AccessType });
|
||||||
@@ -262,6 +302,8 @@ const ExamEditor: React.FC<{
|
|||||||
value={{ value: access, label: capitalize(access) }}
|
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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { checkAccess } from "@/utils/permissions";
|
|||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import useRecordStore from "@/stores/recordStore";
|
import useRecordStore from "@/stores/recordStore";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { mapBy } from "@/utils";
|
import { mapBy } from "@/utils";
|
||||||
@@ -44,13 +42,13 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
|
|
||||||
const [entity, setEntity] = useState<string>();
|
const [entity, setEntity] = useState<string>();
|
||||||
|
|
||||||
const [, setStatsUserId] = useRecordStore((state) => [
|
const [selectedUser, setStatsUserId] = useRecordStore((state) => [
|
||||||
state.selectedUser,
|
state.selectedUser,
|
||||||
state.setSelectedUser,
|
state.setSelectedUser,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const entitiesToSearch = useMemo(() => {
|
const entitiesToSearch = useMemo(() => {
|
||||||
if(entity) return entity
|
if (entity) return entity;
|
||||||
if (isAdmin) return undefined;
|
if (isAdmin) return undefined;
|
||||||
return mapBy(entities, "id");
|
return mapBy(entities, "id");
|
||||||
}, [entities, entity, isAdmin]);
|
}, [entities, entity, isAdmin]);
|
||||||
@@ -69,6 +67,14 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
"view_student_record"
|
"view_student_record"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedUserValue = useMemo(
|
||||||
|
() =>
|
||||||
|
users.find((u) => u.id === selectedUser) || {
|
||||||
|
value: user.id,
|
||||||
|
label: `${user.name} - ${user.email}`,
|
||||||
|
},
|
||||||
|
[selectedUser, user, users]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
||||||
|
|
||||||
@@ -118,10 +124,7 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
loadOptions={loadOptions}
|
loadOptions={loadOptions}
|
||||||
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
||||||
options={users}
|
options={users}
|
||||||
defaultValue={{
|
defaultValue={selectedUserValue}
|
||||||
value: user.id,
|
|
||||||
label: `${user.name} - ${user.email}`,
|
|
||||||
}}
|
|
||||||
onChange={(value) => setStatsUserId(value?.value!)}
|
onChange={(value) => setStatsUserId(value?.value!)}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import { Type, User } from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -15,19 +13,21 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import CodeGenImportSummary, { ExcelCodegenDuplicatesMap } from "@/components/ImportSummaries/Codegen";
|
import CodeGenImportSummary, {
|
||||||
|
ExcelCodegenDuplicatesMap,
|
||||||
|
} from "@/components/ImportSummaries/Codegen";
|
||||||
import { FaFileDownload } from "react-icons/fa";
|
import { FaFileDownload } from "react-icons/fa";
|
||||||
import { IoInformationCircleOutline } from "react-icons/io5";
|
import { IoInformationCircleOutline } from "react-icons/io5";
|
||||||
import { HiOutlineDocumentText } from "react-icons/hi";
|
import { HiOutlineDocumentText } from "react-icons/hi";
|
||||||
import CodegenTable from "@/components/Tables/CodeGenTable";
|
import CodegenTable from "@/components/Tables/CodeGenTable";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
@@ -54,11 +54,26 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,22 +81,38 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) {
|
export default function BatchCodeGenerator({
|
||||||
const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]);
|
user,
|
||||||
|
users,
|
||||||
|
entities = [],
|
||||||
|
permissions,
|
||||||
|
onFinish,
|
||||||
|
}: Props) {
|
||||||
|
const [infos, setInfos] = useState<
|
||||||
|
{ email: string; name: string; passport_id: string }[]
|
||||||
|
>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined });
|
const [parsedExcel, setParsedExcel] = useState<{
|
||||||
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>();
|
rows?: any[];
|
||||||
|
errors?: any[];
|
||||||
|
}>({ rows: undefined, errors: undefined });
|
||||||
|
const [duplicatedRows, setDuplicatedRows] = useState<{
|
||||||
|
duplicates: ExcelCodegenDuplicatesMap;
|
||||||
|
count: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
@@ -94,62 +125,62 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
'First Name': {
|
"First Name": {
|
||||||
prop: 'firstName',
|
prop: "firstName",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('First Name cannot be empty')
|
throw new Error("First Name cannot be empty");
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
'Last Name': {
|
},
|
||||||
prop: 'lastName',
|
"Last Name": {
|
||||||
|
prop: "lastName",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Last Name cannot be empty')
|
throw new Error("Last Name cannot be empty");
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
'Passport/National ID': {
|
},
|
||||||
prop: 'passport_id',
|
"Passport/National ID": {
|
||||||
|
prop: "passport_id",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Passport/National ID cannot be empty')
|
throw new Error("Passport/National ID cannot be empty");
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
'E-mail': {
|
},
|
||||||
prop: 'email',
|
"E-mail": {
|
||||||
|
prop: "email",
|
||||||
required: true,
|
required: true,
|
||||||
type: (value: any) => {
|
type: (value: any) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Email cannot be empty')
|
throw new Error("Email cannot be empty");
|
||||||
}
|
}
|
||||||
if (!EMAIL_REGEX.test(value.trim())) {
|
if (!EMAIL_REGEX.test(value.trim())) {
|
||||||
throw new Error('Invalid Email')
|
throw new Error("Invalid Email");
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
readXlsxFile(
|
readXlsxFile(file.content, { schema, ignoreEmptyRows: false }).then(
|
||||||
file.content, { schema, ignoreEmptyRows: false })
|
(data) => {
|
||||||
.then((data) => {
|
setParsedExcel(data);
|
||||||
setParsedExcel(data)
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
@@ -164,12 +195,14 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
const duplicateRowIndices = new Set<number>();
|
const duplicateRowIndices = new Set<number>();
|
||||||
|
|
||||||
const errorRowIndices = new Set(
|
const errorRowIndices = new Set(
|
||||||
parsedExcel.errors?.map(error => error.row) || []
|
parsedExcel.errors?.map((error) => error.row) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
parsedExcel.rows.forEach((row, index) => {
|
parsedExcel.rows.forEach((row, index) => {
|
||||||
if (!errorRowIndices.has(index + 2)) {
|
if (!errorRowIndices.has(index + 2)) {
|
||||||
(Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).forEach(field => {
|
(
|
||||||
|
Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
|
||||||
|
).forEach((field) => {
|
||||||
if (row !== null) {
|
if (row !== null) {
|
||||||
const value = row[field];
|
const value = row[field];
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -180,7 +213,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
if (existingRows) {
|
if (existingRows) {
|
||||||
existingRows.push(index + 2);
|
existingRows.push(index + 2);
|
||||||
duplicateValues.add(value);
|
duplicateValues.add(value);
|
||||||
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
|
existingRows.forEach((rowNum) =>
|
||||||
|
duplicateRowIndices.add(rowNum)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,10 +226,23 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
|
|
||||||
const info = parsedExcel.rows
|
const info = parsedExcel.rows
|
||||||
.map((row, index) => {
|
.map((row, index) => {
|
||||||
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
|
if (
|
||||||
|
errorRowIndices.has(index + 2) ||
|
||||||
|
duplicateRowIndices.has(index + 2) ||
|
||||||
|
row === null
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
|
const {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
studentID,
|
||||||
|
passport_id,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
group,
|
||||||
|
country,
|
||||||
|
} = row;
|
||||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -204,31 +252,49 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
};
|
};
|
||||||
}).filter((x) => !!x) as typeof infos;
|
})
|
||||||
|
.filter((x) => !!x) as typeof infos;
|
||||||
|
|
||||||
setInfos(info);
|
setInfos(info);
|
||||||
}
|
}
|
||||||
}, [entity, parsedExcel, type]);
|
}, [entity, parsedExcel, type]);
|
||||||
|
|
||||||
const generateAndInvite = async () => {
|
const generateAndInvite = async () => {
|
||||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
const newUsers = infos.filter(
|
||||||
|
(x) => !users.map((u) => u.email).includes(x.email)
|
||||||
|
);
|
||||||
const existingUsers = infos
|
const existingUsers = infos
|
||||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
.map((i) => users.find((u) => u.email === i.email))
|
.map((i) => users.find((u) => u.email === i.email))
|
||||||
.filter((x) => !!x && x.type === "student") as User[];
|
.filter((x) => !!x && x.type === "student") as User[];
|
||||||
|
|
||||||
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
const newUsersSentence =
|
||||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||||
|
const existingUsersSentence =
|
||||||
|
existingUsers.length > 0
|
||||||
|
? `invite ${existingUsers.length} registered student(s)`
|
||||||
|
: undefined;
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
`You are about to ${[newUsersSentence, existingUsersSentence]
|
||||||
|
.filter((x) => !!x)
|
||||||
|
.join(" and ")}, are you sure you want to continue?`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id })))
|
Promise.all(
|
||||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
existingUsers.map(
|
||||||
|
async (u) =>
|
||||||
|
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||||
|
)
|
||||||
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -246,17 +312,20 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||||
type,
|
type,
|
||||||
codes,
|
codes,
|
||||||
infos: informations.map((info, index) => ({ ...info, code: codes[index] })),
|
infos: informations.map((info, index) => ({
|
||||||
|
...info,
|
||||||
|
code: codes[index],
|
||||||
|
})),
|
||||||
expiryDate,
|
expiryDate,
|
||||||
entity
|
entity,
|
||||||
})
|
})
|
||||||
.then(({ data, status }) => {
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
`Successfully generated${
|
||||||
type,
|
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
||||||
)} codes and they have been notified by e-mail!`,
|
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
||||||
{ toastId: "success" },
|
{ toastId: "success" }
|
||||||
);
|
);
|
||||||
|
|
||||||
onFinish();
|
onFinish();
|
||||||
@@ -287,7 +356,7 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
const fileName = "BatchCodeTemplate.xlsx";
|
const fileName = "BatchCodeTemplate.xlsx";
|
||||||
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
@@ -301,11 +370,15 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
<>
|
<>
|
||||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
||||||
<>
|
<>
|
||||||
<div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div>
|
<div className="flex font-bold text-xl justify-center text-gray-700">
|
||||||
|
<span>Excel File Format</span>
|
||||||
|
</div>
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} />
|
<HiOutlineDocumentText
|
||||||
|
className={`w-5 h-5 text-mti-purple-light`}
|
||||||
|
/>
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
The uploaded document must:
|
The uploaded document must:
|
||||||
</h2>
|
</h2>
|
||||||
@@ -315,15 +388,24 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
be an Excel .xlsx document.
|
be an Excel .xlsx document.
|
||||||
</li>
|
</li>
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
only have a single spreadsheet with the following <b>exact same name</b> columns:
|
only have a single spreadsheet with the following{" "}
|
||||||
|
<b>exact same name</b> columns:
|
||||||
<div className="py-4 pr-4">
|
<div className="py-4 pr-4">
|
||||||
<table className="w-full bg-white">
|
<table className="w-full bg-white">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
First Name
|
||||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Last Name
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Passport/National ID
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
E-mail
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
@@ -333,10 +415,10 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} />
|
<IoInformationCircleOutline
|
||||||
<h2 className="text-lg font-semibold">
|
className={`w-5 h-5 text-mti-purple-light`}
|
||||||
Note that:
|
/>
|
||||||
</h2>
|
<h2 className="text-lg font-semibold">Note that:</h2>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-col pl-10 gap-2">
|
<ul className="flex flex-col pl-10 gap-2">
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
@@ -346,10 +428,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
all already registered e-mails will be ignored.
|
all already registered e-mails will be ignored.
|
||||||
</li>
|
</li>
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
all rows which contain duplicate values in the columns: "Passport/National ID", "E-mail", will be ignored.
|
all rows which contain duplicate values in the columns:
|
||||||
|
"Passport/National ID", "E-mail", will be
|
||||||
|
ignored.
|
||||||
</li>
|
</li>
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
|
all of the e-mails in the file will receive an e-mail to join
|
||||||
|
EnCoach with the role selected below.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,11 +444,21 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex justify-between mt-6 gap-8">
|
<div className="w-full flex justify-between mt-6 gap-8">
|
||||||
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => setShowHelp(false)}
|
||||||
|
variant="outline"
|
||||||
|
className="self-end w-full bg-white"
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={handleTemplateDownload}
|
||||||
|
variant="solid"
|
||||||
|
className="self-end w-full"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FaFileDownload size={24} />
|
<FaFileDownload size={24} />
|
||||||
Download Template
|
Download Template
|
||||||
@@ -375,7 +470,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</Modal>
|
</Modal>
|
||||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Choose an Excel file
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowHelp(true)}
|
onClick={() => setShowHelp(true)}
|
||||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||||
@@ -384,14 +481,30 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
<IoInformationCircleOutline size={24} />
|
<IoInformationCircleOutline size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
<Button
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
</Button>
|
</Button>
|
||||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{user &&
|
||||||
|
checkAccess(user, [
|
||||||
|
"developer",
|
||||||
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
Expiry Date
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={isExpiryDateEnabled}
|
||||||
|
onChange={setIsExpiryDateEnabled}
|
||||||
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
|
>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,11 +513,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
filterDate={(date) =>
|
filterDate={(date) =>
|
||||||
moment(date).isAfter(new Date()) &&
|
moment(date).isAfter(new Date()) &&
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
(user.subscriptionExpirationDate
|
||||||
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
|
: true)
|
||||||
}
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={expiryDate}
|
selected={expiryDate}
|
||||||
@@ -414,41 +529,67 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Entity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
defaultValue={{
|
||||||
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Select the type of user they should be
|
||||||
|
</label>
|
||||||
{user && (
|
{user && (
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
>
|
||||||
.filter((x) => {
|
{Object.keys(USER_TYPE_LABELS).reduce((acc, x) => {
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
})
|
acc.push(
|
||||||
.map((type) => (
|
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
return acc;
|
||||||
|
}, [] as JSX.Element[])}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>}
|
{infos.length > 0 && (
|
||||||
|
<CodeGenImportSummary
|
||||||
|
infos={infos}
|
||||||
|
parsedExcel={parsedExcel}
|
||||||
|
duplicateRows={duplicatedRows}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{infos.length !== 0 && (
|
{infos.length !== 0 && (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span>
|
<span className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Codes will be sent to:
|
||||||
|
</span>
|
||||||
<CodegenTable infos={infos} />
|
<CodegenTable infos={infos} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
{checkAccess(
|
||||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
user,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"createCodes"
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
onClick={generateAndInvite}
|
||||||
|
disabled={
|
||||||
|
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||||
|
}
|
||||||
|
>
|
||||||
Generate & Send
|
Generate & Send
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
import { Type, User } from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -13,10 +12,8 @@ import { toast } from "react-toastify";
|
|||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
@@ -43,30 +40,52 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) {
|
export default function CodeGenerator({
|
||||||
|
user,
|
||||||
|
entities = [],
|
||||||
|
permissions,
|
||||||
|
onFinish,
|
||||||
|
}: Props) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
|
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
@@ -105,11 +124,18 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
User Code Generator
|
||||||
|
</label>
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Entity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
defaultValue={{
|
||||||
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
@@ -121,25 +147,33 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
>
|
||||||
.filter((x) => {
|
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
})
|
acc.push(x);
|
||||||
.map((type) => (
|
return acc;
|
||||||
<option key={type} value={type}>
|
}, [])}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{checkAccess(user, [
|
||||||
|
"developer",
|
||||||
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
Expiry Date
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={isExpiryDateEnabled}
|
||||||
|
onChange={setIsExpiryDateEnabled}
|
||||||
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
|
>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,11 +182,13 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
filterDate={(date) =>
|
filterDate={(date) =>
|
||||||
moment(date).isAfter(new Date()) &&
|
moment(date).isAfter(new Date()) &&
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
(user.subscriptionExpirationDate
|
||||||
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
|
: true)
|
||||||
}
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={expiryDate}
|
selected={expiryDate}
|
||||||
@@ -161,25 +197,40 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
{checkAccess(
|
||||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
user,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"createCodes"
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
onClick={() => generateCode(type)}
|
||||||
|
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
||||||
|
>
|
||||||
Generate
|
Generate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Generated Code:
|
||||||
|
</label>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
data-tip="Click to copy"
|
data-tip="Click to copy"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{generatedCode}
|
{generatedCode}
|
||||||
</div>
|
</div>
|
||||||
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
{generatedCode && (
|
||||||
|
<span className="text-sm text-mti-gray-dim font-light">
|
||||||
|
Give this code to the user to complete their registration
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import { Group, User } from "@/interfaces/user";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
import { CorporateUser, Group, User } from "@/interfaces/user";
|
|
||||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize, uniq } from "lodash";
|
import { uniq } from "lodash";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import { getUserCorporate } from "@/utils/groups";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user";
|
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
|
||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||||
import { WithEntity } from "@/interfaces/entity";
|
import { WithEntity } from "@/interfaces/entity";
|
||||||
|
|
||||||
const searchFields = [["name"]];
|
const searchFields = [["name"]];
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
interface CreateDialogProps {
|
interface CreateDialogProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -35,9 +34,13 @@ interface CreateDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
const [name, setName] = useState<string | undefined>(
|
||||||
|
group?.name || undefined
|
||||||
|
);
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
const [participants, setParticipants] = useState<string[]>(
|
||||||
|
group?.participants || []
|
||||||
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
@@ -47,9 +50,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const availableUsers = useMemo(() => {
|
const availableUsers = useMemo(() => {
|
||||||
if (user?.type === "teacher") return users.filter((x) => ["student"].includes(x.type));
|
if (user?.type === "teacher")
|
||||||
if (user?.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type));
|
return users.filter((x) => ["student"].includes(x.type));
|
||||||
if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type));
|
if (user?.type === "corporate")
|
||||||
|
return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||||
|
if (user?.type === "mastercorporate")
|
||||||
|
return users.filter((x) =>
|
||||||
|
["corporate", "teacher", "student"].includes(x.type)
|
||||||
|
);
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}, [user, users]);
|
}, [user, users]);
|
||||||
@@ -64,9 +72,12 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [email] = row as string[];
|
const [email] = row as string[];
|
||||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
return EMAIL_REGEX.test(email) &&
|
||||||
|
!users.map((u) => u.email).includes(email)
|
||||||
|
? email.toString().trim()
|
||||||
|
: undefined;
|
||||||
})
|
})
|
||||||
.filter((x) => !!x),
|
.filter((x) => !!x)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
@@ -76,12 +87,17 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
const emailUsers = [...new Set(emails)]
|
||||||
|
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||||
|
.filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
|
((user.type === "developer" ||
|
||||||
|
user.type === "admin" ||
|
||||||
|
user.type === "corporate" ||
|
||||||
|
user.type === "mastercorporate") &&
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student")
|
||||||
);
|
);
|
||||||
|
|
||||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||||
@@ -89,7 +105,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
user.type !== "teacher"
|
user.type !== "teacher"
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
: "Added all students found in the file you've provided!",
|
: "Added all students found in the file you've provided!",
|
||||||
{ toastId: "upload-success" },
|
{ toastId: "upload-success" }
|
||||||
);
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -100,15 +116,27 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
|
if (
|
||||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
name !== group?.name &&
|
||||||
|
(name?.trim() === "Students" ||
|
||||||
|
name?.trim() === "Teachers" ||
|
||||||
|
name?.trim() === "Corporate")
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"That group name is reserved and cannot be used, please enter another one."
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants })
|
(group ? axios.patch : axios.post)(
|
||||||
|
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||||
|
{ name, admin, participants }
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
toast.success(
|
||||||
|
`Group "${name}" ${group ? "edited" : "created"} successfully`
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -121,30 +149,58 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
availableUsers.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.email} - ${x.name}`,
|
||||||
|
})),
|
||||||
|
[availableUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() =>
|
||||||
|
participants.map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: `${users.find((y) => y.id === x)?.email} - ${
|
||||||
|
users.find((y) => y.id === x)?.name
|
||||||
|
}`,
|
||||||
|
})),
|
||||||
|
[participants, users]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
<Input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label="Name"
|
||||||
|
defaultValue={name}
|
||||||
|
onChange={setName}
|
||||||
|
required
|
||||||
|
disabled={group?.disableEditing}
|
||||||
|
/>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
Participants
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||||
|
>
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full gap-8">
|
<div className="flex w-full gap-8">
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={participants.map((x) => ({
|
value={value}
|
||||||
value: x,
|
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
placeholder="Participants..."
|
placeholder="Participants..."
|
||||||
defaultValue={participants.map((x) => ({
|
defaultValue={value}
|
||||||
value: x,
|
options={userOptions}
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
options={availableUsers.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
isMulti
|
isMulti
|
||||||
isSearchable
|
isSearchable
|
||||||
@@ -160,18 +216,36 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
<Button
|
||||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
className="w-full max-w-[300px] h-fit"
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{filesContent.length === 0
|
||||||
|
? "Upload participants Excel file"
|
||||||
|
: filesContent[0].name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={submit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!name}
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +256,8 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
export default function GroupList({ user }: { user: User }) {
|
export default function GroupList({ user }: { user: User }) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
const [viewingAllParticipants, setViewingAllParticipants] =
|
||||||
|
useState<string>();
|
||||||
|
|
||||||
const { permissions } = usePermissions(user?.id || "");
|
const { permissions } = usePermissions(user?.id || "");
|
||||||
|
|
||||||
@@ -211,7 +286,14 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
columnHelper.accessor("admin", {
|
columnHelper.accessor("admin", {
|
||||||
header: "Admin",
|
header: "Admin",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip={
|
||||||
|
USER_TYPE_LABELS[
|
||||||
|
users.find((x) => x.id === info.getValue())?.type || "student"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -226,20 +308,27 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
<span>
|
<span>
|
||||||
{info
|
{info
|
||||||
.getValue()
|
.getValue()
|
||||||
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
.slice(
|
||||||
|
0,
|
||||||
|
viewingAllParticipants === info.row.original.id ? undefined : 5
|
||||||
|
)
|
||||||
.map((x) => users.find((y) => y.id === x)?.name)
|
.map((x) => users.find((y) => y.id === x)?.name)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
|
{info.getValue().length > 5 &&
|
||||||
|
viewingAllParticipants !== info.row.original.id && (
|
||||||
<button
|
<button
|
||||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
onClick={() => setViewingAllParticipants(info.row.original.id)}>
|
onClick={() => setViewingAllParticipants(info.row.original.id)}
|
||||||
|
>
|
||||||
, View More
|
, View More
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
|
{info.getValue().length > 5 &&
|
||||||
|
viewingAllParticipants === info.row.original.id && (
|
||||||
<button
|
<button
|
||||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
onClick={() => setViewingAllParticipants(undefined)}>
|
onClick={() => setViewingAllParticipants(undefined)}
|
||||||
|
>
|
||||||
, View Less
|
, View Less
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -252,15 +341,29 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
cell: ({ row }: { row: { original: Group } }) => {
|
cell: ({ row }: { row: { original: Group } }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
{user &&
|
||||||
|
(checkAccess(user, ["developer", "admin"]) ||
|
||||||
|
user.id === row.original.admin) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
|
{(!row.original.disableEditing ||
|
||||||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
checkAccess(user, ["developer", "admin"]),
|
||||||
|
"editGroup") && (
|
||||||
|
<div
|
||||||
|
data-tip="Edit"
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
onClick={() => setEditingGroup(row.original)}
|
||||||
|
>
|
||||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
|
{(!row.original.disableEditing ||
|
||||||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
checkAccess(user, ["developer", "admin"]),
|
||||||
|
"deleteGroup") && (
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
onClick={() => deleteGroup(row.original)}
|
||||||
|
>
|
||||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -280,7 +383,11 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
||||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
<Modal
|
||||||
|
isOpen={isCreating || !!editingGroup}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||||
|
>
|
||||||
<CreatePanel
|
<CreatePanel
|
||||||
group={editingGroup}
|
group={editingGroup}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -288,12 +395,22 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
users={users}
|
users={users}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Table data={groups} columns={defaultColumns} searchFields={searchFields} />
|
<Table
|
||||||
|
data={groups}
|
||||||
|
columns={defaultColumns}
|
||||||
|
searchFields={searchFields}
|
||||||
|
/>
|
||||||
|
|
||||||
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
|
{checkAccess(
|
||||||
|
user,
|
||||||
|
["teacher", "corporate", "mastercorporate", "admin", "developer"],
|
||||||
|
permissions,
|
||||||
|
"createGroup"
|
||||||
|
) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||||
|
>
|
||||||
New Group
|
New Group
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import usePackages from "@/hooks/usePackages";
|
import usePackages from "@/hooks/usePackages";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Package} from "@/interfaces/paypal";
|
import { Package } from "@/interfaces/paypal";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import {useState} from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {BsPencil, BsTrash} from "react-icons/bs";
|
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {CURRENCIES} from "@/resources/paypal";
|
import { CURRENCIES } from "@/resources/paypal";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
|
|
||||||
const CLASSES: {[key in Module]: string} = {
|
const CLASSES: { [key in Module]: string } = {
|
||||||
reading: "text-ielts-reading",
|
reading: "text-ielts-reading",
|
||||||
listening: "text-ielts-listening",
|
listening: "text-ielts-listening",
|
||||||
speaking: "text-ielts-speaking",
|
speaking: "text-ielts-speaking",
|
||||||
@@ -26,20 +31,36 @@ const columnHelper = createColumnHelper<Package>();
|
|||||||
|
|
||||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||||
|
|
||||||
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
|
const currencyOptions = CURRENCIES.map(({ label, currency }) => ({
|
||||||
|
value: currency,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function PackageCreator({
|
||||||
|
pack,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
pack?: Package;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
const [unit, setUnit] = useState<DurationUnit>(
|
||||||
|
pack?.duration_unit || "months"
|
||||||
|
);
|
||||||
|
|
||||||
const [price, setPrice] = useState(pack?.price || 0);
|
const [price, setPrice] = useState(pack?.price || 0);
|
||||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||||
|
|
||||||
const submit = () => {
|
const submit = useCallback(() => {
|
||||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
(pack ? axios.patch : axios.post)(
|
||||||
|
pack ? `/api/packages/${pack.id}` : "/api/packages",
|
||||||
|
{
|
||||||
duration,
|
duration,
|
||||||
duration_unit: unit,
|
duration_unit: unit,
|
||||||
price,
|
price,
|
||||||
currency,
|
currency,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("New payment has been created successfully!");
|
toast.success("New payment has been created successfully!");
|
||||||
onClose();
|
onClose();
|
||||||
@@ -47,24 +68,38 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong, please try again later!");
|
toast.error("Something went wrong, please try again later!");
|
||||||
});
|
});
|
||||||
|
}, [duration, unit, price, currency, pack, onClose]);
|
||||||
|
|
||||||
|
const currencyDefaultValue = useMemo(() => {
|
||||||
|
return {
|
||||||
|
value: currency || "EUR",
|
||||||
|
label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
|
||||||
};
|
};
|
||||||
|
}, [currency]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 py-8">
|
<div className="flex flex-col gap-8 py-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Price *
|
||||||
|
</label>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
<Input
|
||||||
|
defaultValue={price}
|
||||||
|
name="price"
|
||||||
|
type="number"
|
||||||
|
onChange={(e) => setPrice(parseInt(e))}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
options={currencyOptions}
|
||||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
defaultValue={currencyDefaultValue}
|
||||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
value={currencyDefaultValue}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
@@ -76,7 +111,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused
|
||||||
|
? "#D5D9F0"
|
||||||
|
: state.isSelected
|
||||||
|
? "#7872BF"
|
||||||
|
: "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -84,23 +123,32 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Duration *
|
||||||
|
</label>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
|
<Input
|
||||||
|
defaultValue={duration}
|
||||||
|
name="duration"
|
||||||
|
type="number"
|
||||||
|
onChange={(e) => setDuration(parseInt(e))}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={[
|
options={[
|
||||||
{value: "days", label: "Days"},
|
{ value: "days", label: "Days" },
|
||||||
{value: "weeks", label: "Weeks"},
|
{ value: "weeks", label: "Weeks" },
|
||||||
{value: "months", label: "Months"},
|
{ value: "months", label: "Months" },
|
||||||
{value: "years", label: "Years"},
|
{ value: "years", label: "Years" },
|
||||||
]}
|
]}
|
||||||
defaultValue={{value: "months", label: "Months"}}
|
defaultValue={{ value: "months", label: "Months" }}
|
||||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
onChange={(value) =>
|
||||||
value={{value: unit, label: capitalize(unit)}}
|
setUnit((value?.value as DurationUnit) || "months")
|
||||||
|
}
|
||||||
|
value={{ value: unit, label: capitalize(unit) }}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
@@ -112,7 +160,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused
|
||||||
|
? "#D5D9F0"
|
||||||
|
: state.isSelected
|
||||||
|
? "#7872BF"
|
||||||
|
: "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -120,10 +172,19 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!duration || !price}
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,13 +192,14 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageList({user}: {user: User}) {
|
export default function PackageList({ user }: { user: User }) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||||
|
|
||||||
const {packages, reload} = usePackages();
|
const { packages, reload } = usePackages();
|
||||||
|
|
||||||
const deletePackage = async (pack: Package) => {
|
const deletePackage = useCallback(
|
||||||
|
async (pack: Package) => {
|
||||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -157,9 +219,12 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
toast.error("Something went wrong, please try again later.");
|
toast.error("Something went wrong, please try again later.");
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
},
|
||||||
|
[reload]
|
||||||
|
);
|
||||||
|
|
||||||
const defaultColumns = [
|
const defaultColumns = useMemo(
|
||||||
|
() => [
|
||||||
columnHelper.accessor("id", {
|
columnHelper.accessor("id", {
|
||||||
header: "ID",
|
header: "ID",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
@@ -183,16 +248,24 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({row}: {row: {original: Package}}) => {
|
cell: ({ row }: { row: { original: Package } }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{["developer", "admin"].includes(user.type) && (
|
{["developer", "admin"].includes(user?.type) && (
|
||||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
|
<div
|
||||||
|
data-tip="Edit"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => setEditingPackage(row.original)}
|
||||||
|
>
|
||||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{["developer", "admin"].includes(user.type) && (
|
{["developer", "admin"].includes(user?.type) && (
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => deletePackage(row.original)}
|
||||||
|
>
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +273,9 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[deletePackage, user]
|
||||||
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: packages,
|
data: packages,
|
||||||
@@ -208,18 +283,19 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = useCallback(() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setEditingPackage(undefined);
|
setEditingPackage(undefined);
|
||||||
reload();
|
reload();
|
||||||
};
|
}, [reload]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full rounded-xl">
|
<div className="w-full h-full rounded-xl">
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isCreating || !!editingPackage}
|
isOpen={isCreating || !!editingPackage}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
|
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
|
||||||
|
>
|
||||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||||
@@ -228,7 +304,12 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="p-4 text-left" key={header.id}>
|
<th className="p-4 text-left" key={header.id}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -236,7 +317,10 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<tr
|
||||||
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -248,7 +332,8 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
</table>
|
</table>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
|
||||||
|
>
|
||||||
New Package
|
New Package
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,31 +1,23 @@
|
|||||||
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",
|
||||||
@@ -49,11 +41,26 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +72,13 @@ interface Props {
|
|||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserCreator({user, users, entities = [], permissions, onFinish}: Props) {
|
export default function UserCreator({
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
entities = [],
|
||||||
|
permissions,
|
||||||
|
onFinish,
|
||||||
|
}: Props) {
|
||||||
const [name, setName] = useState<string>();
|
const [name, setName] = useState<string>();
|
||||||
const [email, setEmail] = useState<string>();
|
const [email, setEmail] = useState<string>();
|
||||||
const [phone, setPhone] = useState<string>();
|
const [phone, setPhone] = useState<string>();
|
||||||
@@ -76,7 +89,9 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
const [password, setPassword] = useState<string>();
|
const [password, setPassword] = useState<string>();
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>();
|
const [confirmPassword, setConfirmPassword] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user?.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -84,18 +99,23 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
const [position, setPosition] = useState<string>();
|
const [position, setPosition] = useState<string>();
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
|
|
||||||
const {groups} = useEntitiesGroups();
|
const { groups } = useEntitiesGroups();
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -130,7 +150,11 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
setCountry(user?.demographicInformation?.country);
|
setCountry(user?.demographicInformation?.country);
|
||||||
setGroup(null);
|
setGroup(null);
|
||||||
setEntity((entities || [])[0]?.id || undefined);
|
setEntity((entities || [])[0]?.id || undefined);
|
||||||
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
|
setExpiryDate(
|
||||||
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user?.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
|
);
|
||||||
setIsExpiryDateEnabled(true);
|
setIsExpiryDateEnabled(true);
|
||||||
setType("student");
|
setType("student");
|
||||||
setPosition(undefined);
|
setPosition(undefined);
|
||||||
@@ -146,10 +170,34 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
|
<Input
|
||||||
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
|
required
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="E-mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
@@ -161,11 +209,21 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Country *
|
||||||
|
</label>
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
label="Phone number"
|
||||||
|
value={phone}
|
||||||
|
onChange={setPhone}
|
||||||
|
placeholder="Phone number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
{type === "student" && (
|
{type === "student" && (
|
||||||
<>
|
<>
|
||||||
@@ -178,28 +236,51 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
placeholder="National ID or Passport number"
|
placeholder="National ID or Passport number"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="studentID"
|
||||||
|
label="Student ID"
|
||||||
|
onChange={setStudentID}
|
||||||
|
value={studentID}
|
||||||
|
placeholder="Student ID"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Entity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={{value: (entities || [])[0]?.id, label: (entities || [])[0]?.label}}
|
defaultValue={{
|
||||||
options={entities.map((e) => ({value: e.id, label: e.label}))}
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{["corporate", "mastercorporate"].includes(type) && (
|
{["corporate", "mastercorporate"].includes(type) && (
|
||||||
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
onChange={setPosition}
|
||||||
|
value={position}
|
||||||
|
placeholder="Department"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Classroom
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
options={groups.filter((x) => x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))}
|
options={groups
|
||||||
|
.filter((x) => x.entity?.id === entity)
|
||||||
|
.map((g) => ({ value: g.id, label: g.name }))}
|
||||||
onChange={(e) => setGroup(e?.value || undefined)}
|
onChange={(e) => setGroup(e?.value || undefined)}
|
||||||
isClearable
|
isClearable
|
||||||
/>
|
/>
|
||||||
@@ -208,38 +289,52 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col gap-4",
|
"flex flex-col gap-4",
|
||||||
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
|
!checkAccess(user, [
|
||||||
)}>
|
"developer",
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && "col-span-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
{user && (
|
{user && (
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as Type)}
|
onChange={(e) => setType(e.target.value as Type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
>
|
||||||
.filter((x) => {
|
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||||
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
})
|
acc.push(x);
|
||||||
.map((type) => (
|
return acc;
|
||||||
<option key={type} value={type}>
|
}, [])}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{user &&
|
||||||
|
checkAccess(user, [
|
||||||
|
"developer",
|
||||||
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Expiry Date
|
||||||
|
</label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={isExpiryDateEnabled}
|
isChecked={isExpiryDateEnabled}
|
||||||
onChange={setIsExpiryDateEnabled}
|
onChange={setIsExpiryDateEnabled}
|
||||||
disabled={!!user?.subscriptionExpirationDate}>
|
disabled={!!user?.subscriptionExpirationDate}
|
||||||
|
>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,11 +343,15 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
filterDate={(date) =>
|
filterDate={(date) =>
|
||||||
moment(date).isAfter(new Date()) &&
|
moment(date).isAfter(new Date()) &&
|
||||||
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
|
(user?.subscriptionExpirationDate
|
||||||
|
? moment(date).isBefore(
|
||||||
|
user?.subscriptionExpirationDate
|
||||||
|
)
|
||||||
|
: true)
|
||||||
}
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={expiryDate}
|
selected={expiryDate}
|
||||||
@@ -264,7 +363,11 @@ export default function UserCreator({user, users, entities = [], permissions, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
|
<Button
|
||||||
|
onClick={createUser}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
|
||||||
|
>
|
||||||
Create User
|
Create User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ export default function RegisterCorporate({
|
|||||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||||
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers({ type: "agent" });
|
||||||
|
|
||||||
const onSuccess = () =>
|
const onSuccess = () =>
|
||||||
toast.success(
|
toast.success(
|
||||||
"An e-mail has been sent, please make sure to check your spam folder!",
|
"An e-mail has been sent, please make sure to check your spam folder!"
|
||||||
);
|
);
|
||||||
|
|
||||||
const onError = (e: Error) => {
|
const onError = (e: Error) => {
|
||||||
@@ -83,7 +83,7 @@ export default function RegisterCorporate({
|
|||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
mutateUser(response.data.user).then(() =>
|
mutateUser(response.data.user).then(() =>
|
||||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
sendEmailVerification(setIsLoading, onSuccess, onError)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -178,9 +178,10 @@ export default function RegisterCorporate({
|
|||||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "No referral" },
|
{ value: "", label: "No referral" },
|
||||||
...users
|
...users.map((x) => ({
|
||||||
.filter((u) => u.type === "agent")
|
value: x.id,
|
||||||
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
|
label: `${x.name} - ${x.email}`,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
defaultValue={{ value: "", label: "No referral" }}
|
defaultValue={{ value: "", label: "No referral" }}
|
||||||
onChange={(value) => setReferralAgent(value?.value)}
|
onChange={(value) => setReferralAgent(value?.value)}
|
||||||
@@ -229,7 +230,7 @@ export default function RegisterCorporate({
|
|||||||
? availableDurations[
|
? availableDurations[
|
||||||
value.value as keyof typeof availableDurations
|
value.value as keyof typeof availableDurations
|
||||||
].number
|
].number
|
||||||
: 1,
|
: 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -23,5 +23,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const entityIdsArray = entityIdsString.split(",");
|
const entityIdsArray = entityIdsString.split(",");
|
||||||
|
|
||||||
|
if (!["admin", "developer"].includes(user.type)) {
|
||||||
|
// filtering workflows that have user as assignee in at least one of the steps
|
||||||
|
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray, undefined, user.id));
|
||||||
|
} else {
|
||||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
|
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// 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,6 +19,24 @@ const db = client.db(process.env.MONGODB_DB);
|
|||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
// Temporary: Adding UUID here but later move to backend.
|
||||||
|
function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
|
||||||
|
const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
|
||||||
|
|
||||||
|
exam.parts = exam.parts.map((part) => {
|
||||||
|
const updatedExercises = part.exercises.map((exercise: any) => {
|
||||||
|
arraysToUpdate.forEach((arrayName) => {
|
||||||
|
if (exercise[arrayName] && Array.isArray(exercise[arrayName])) {
|
||||||
|
exercise[arrayName] = exercise[arrayName].map((item: any) => (item.uuid ? item : { ...item, uuid: uuidv4() }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return exercise;
|
||||||
|
});
|
||||||
|
return { ...part, exercises: updatedExercises };
|
||||||
|
});
|
||||||
|
return exam;
|
||||||
|
}
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return await GET(req, res);
|
if (req.method === "GET") return await GET(req, res);
|
||||||
if (req.method === "POST") return await POST(req, res);
|
if (req.method === "POST") return await POST(req, res);
|
||||||
@@ -52,7 +71,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
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,
|
||||||
@@ -61,6 +80,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Temporary: Adding UUID here but later move to backend.
|
||||||
|
exam = addUUIDs(exam);
|
||||||
|
|
||||||
let responseStatus: number;
|
let responseStatus: number;
|
||||||
let responseMessage: string;
|
let responseMessage: string;
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
||||||
{change}
|
<span className="text-mti-purple-light text-lg">{change.charAt(0)}</span>
|
||||||
|
{change.slice(1)}
|
||||||
</p>
|
</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
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export default function AssignmentsPage({
|
|||||||
const [useRandomExams, setUseRandomExams] = useState(true);
|
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||||
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
||||||
|
|
||||||
|
const [showApprovedExams, setShowApprovedExams] = useState<boolean>(true);
|
||||||
|
const [showNonApprovedExams, setShowNonApprovedExams] = useState<boolean>(true);
|
||||||
|
|
||||||
const { exams } = useExams();
|
const { exams } = useExams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -501,6 +504,23 @@ export default function AssignmentsPage({
|
|||||||
Random Exams
|
Random Exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
{!useRandomExams && (
|
{!useRandomExams && (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={showApprovedExams}
|
||||||
|
onChange={() => {
|
||||||
|
setShowApprovedExams((prev) => !prev)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show approved exams
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={showNonApprovedExams}
|
||||||
|
onChange={() => {
|
||||||
|
setShowNonApprovedExams((prev) => !prev)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show non-approved exams
|
||||||
|
</Checkbox>
|
||||||
<div className="grid md:grid-cols-2 w-full gap-4">
|
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||||
{selectedModules.map((module) => (
|
{selectedModules.map((module) => (
|
||||||
<div key={module} className="flex flex-col gap-3 w-full">
|
<div key={module} className="flex flex-col gap-3 w-full">
|
||||||
@@ -508,6 +528,7 @@ export default function AssignmentsPage({
|
|||||||
{capitalize(module)} Exam
|
{capitalize(module)} Exam
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
isClearable
|
||||||
value={{
|
value={{
|
||||||
value:
|
value:
|
||||||
examIDs.find((e) => e.module === module)?.id ||
|
examIDs.find((e) => e.module === module)?.id ||
|
||||||
@@ -526,12 +547,21 @@ export default function AssignmentsPage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
options={exams
|
options={exams
|
||||||
.filter((x) => !x.isDiagnostic && x.module === module)
|
.filter((x) =>
|
||||||
|
!x.isDiagnostic &&
|
||||||
|
x.module === module &&
|
||||||
|
x.access !== "confidential" &&
|
||||||
|
(
|
||||||
|
(x.requiresApproval && showApprovedExams) ||
|
||||||
|
(!x.requiresApproval && showNonApprovedExams)
|
||||||
|
)
|
||||||
|
)
|
||||||
.map((x) => ({ value: x.id, label: x.id }))}
|
.map((x) => ({ value: x.id, label: x.id }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ const EXAM_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{label: "Delete Level", key: "delete_level"},
|
{label: "Delete Level", key: "delete_level"},
|
||||||
{label: "Set as Private/Public", key: "update_exam_privacy"},
|
{label: "Set as Private/Public", key: "update_exam_privacy"},
|
||||||
{label: "View Confidential Exams", key: "view_confidential_exams"},
|
{label: "View Confidential Exams", key: "view_confidential_exams"},
|
||||||
|
{label: "Create Confidential Exams", key: "create_confidential_exams"},
|
||||||
|
{label: "Create Public Exams", key: "create_public_exams"},
|
||||||
{label: "View Statistics", key: "view_statistics"},
|
{label: "View Statistics", key: "view_statistics"},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,93 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {ToastContainer} from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import {Radio, RadioGroup} from "@headlessui/react";
|
import { Radio, RadioGroup } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {findAllowedEntities} from "@/utils/permissions";
|
import {
|
||||||
import {User} from "@/interfaces/user";
|
findAllowedEntities,
|
||||||
|
findAllowedEntitiesSomePermissions,
|
||||||
|
groupAllowedEntitiesByPermissions,
|
||||||
|
} from "@/utils/permissions";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import ExamEditorStore from "@/stores/examEditor/types";
|
import ExamEditorStore from "@/stores/examEditor/types";
|
||||||
import ExamEditor from "@/components/ExamEditor";
|
import ExamEditor from "@/components/ExamEditor";
|
||||||
import {mapBy, redirect, serialize} from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
import {requestUser} from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {getExam} from "@/utils/exams.be";
|
import { getExam } from "@/utils/exams.be";
|
||||||
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam";
|
import {
|
||||||
import {useEffect, useState} from "react";
|
Exam,
|
||||||
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
Exercise,
|
||||||
import {isAdmin} from "@/utils/users";
|
InteractiveSpeakingExercise,
|
||||||
|
ListeningPart,
|
||||||
|
SpeakingExercise,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {EntityWithRoles} from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
type Permission = {[key in Module]: boolean};
|
type Permission = { [key in Module]: boolean };
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
|
async ({ req, res, query }) => {
|
||||||
const user = await requestUser(req, res);
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login");
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/");
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, "id");
|
const entityIDs = mapBy(user.entities, "id");
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
|
|
||||||
|
const entities = await getEntitiesWithRoles(
|
||||||
|
isAdmin(user) ? undefined : entityIDs
|
||||||
|
);
|
||||||
|
|
||||||
|
const generatePermissions = groupAllowedEntitiesByPermissions(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
[
|
||||||
|
"generate_reading",
|
||||||
|
"generate_listening",
|
||||||
|
"generate_writing",
|
||||||
|
"generate_speaking",
|
||||||
|
"generate_level",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const permissions: Permission = {
|
const permissions: Permission = {
|
||||||
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0,
|
reading: generatePermissions["generate_reading"].length > 0,
|
||||||
listening: findAllowedEntities(user, entities, `generate_listening`).length > 0,
|
listening: generatePermissions["generate_listening"].length > 0,
|
||||||
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0,
|
writing: generatePermissions["generate_writing"].length > 0,
|
||||||
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
|
speaking: generatePermissions["generate_speaking"].length > 0,
|
||||||
level: findAllowedEntities(user, entities, `generate_level`).length > 0,
|
level: generatePermissions["generate_level"].length > 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy");
|
const {
|
||||||
console.log(entitiesAllowEditPrivacy);
|
["update_exam_privacy"]: entitiesAllowEditPrivacy,
|
||||||
|
["create_confidential_exams"]: entitiesAllowConfExams,
|
||||||
|
["create_public_exams"]: entitiesAllowPublicExams,
|
||||||
|
} = groupAllowedEntitiesByPermissions(user, entities, [
|
||||||
|
"update_exam_privacy",
|
||||||
|
"create_confidential_exams",
|
||||||
|
"create_public_exams",
|
||||||
|
]);
|
||||||
|
|
||||||
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/");
|
if (Object.keys(permissions).every((p) => !permissions[p as Module]))
|
||||||
|
return redirect("/");
|
||||||
|
|
||||||
const {id, module: examModule} = query as {id?: string; module?: Module};
|
const { id, module: examModule } = query as {
|
||||||
if (!id || !examModule) return {props: serialize({user, permissions})};
|
id?: string;
|
||||||
|
module?: Module;
|
||||||
|
};
|
||||||
|
if (!id || !examModule) return { props: serialize({ user, permissions }) };
|
||||||
|
|
||||||
//if (!permissions[module]) return redirect("/generation")
|
//if (!permissions[module]) return redirect("/generation")
|
||||||
|
|
||||||
@@ -58,9 +95,20 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) =
|
|||||||
if (!exam) return redirect("/generation");
|
if (!exam) return redirect("/generation");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
|
props: serialize({
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
exam,
|
||||||
|
examModule,
|
||||||
|
permissions,
|
||||||
|
entitiesAllowEditPrivacy,
|
||||||
|
entitiesAllowConfExams,
|
||||||
|
entitiesAllowPublicExams,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
},
|
||||||
|
sessionOptions
|
||||||
|
);
|
||||||
|
|
||||||
export default function Generation({
|
export default function Generation({
|
||||||
id,
|
id,
|
||||||
@@ -69,6 +117,8 @@ export default function Generation({
|
|||||||
examModule,
|
examModule,
|
||||||
permissions,
|
permissions,
|
||||||
entitiesAllowEditPrivacy,
|
entitiesAllowEditPrivacy,
|
||||||
|
entitiesAllowConfExams,
|
||||||
|
entitiesAllowPublicExams,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
user: User;
|
user: User;
|
||||||
@@ -76,12 +126,16 @@ export default function Generation({
|
|||||||
examModule?: Module;
|
examModule?: Module;
|
||||||
permissions: Permission;
|
permissions: Permission;
|
||||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||||
|
entitiesAllowPublicExams: EntityWithRoles[];
|
||||||
|
entitiesAllowConfExams: EntityWithRoles[];
|
||||||
}) {
|
}) {
|
||||||
const {title, currentModule, modules, dispatch} = useExamEditorStore();
|
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
||||||
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined);
|
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
||||||
dispatch({type: "UPDATE_ROOT", payload: {updates}});
|
dispatch({ type: "UPDATE_ROOT", payload: { updates } });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,8 +143,8 @@ export default function Generation({
|
|||||||
if (examModule === "level" && exam.module === "level") {
|
if (examModule === "level" && exam.module === "level") {
|
||||||
setExamLevelParts(exam.parts.length);
|
setExamLevelParts(exam.parts.length);
|
||||||
}
|
}
|
||||||
updateRoot({currentModule: examModule});
|
updateRoot({ currentModule: examModule });
|
||||||
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}});
|
dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id, exam, module]);
|
}, [id, exam, module]);
|
||||||
@@ -98,7 +152,7 @@ export default function Generation({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAvatars = async () => {
|
const fetchAvatars = async () => {
|
||||||
const response = await axios.get("/api/exam/avatars");
|
const response = await axios.get("/api/exam/avatars");
|
||||||
updateRoot({speakingAvatars: response.data});
|
updateRoot({ speakingAvatars: response.data });
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAvatars();
|
fetchAvatars();
|
||||||
@@ -124,14 +178,20 @@ export default function Generation({
|
|||||||
sectionId: section.sectionId,
|
sectionId: section.sectionId,
|
||||||
module: "listening",
|
module: "listening",
|
||||||
field: "state",
|
field: "state",
|
||||||
value: {...listeningPart, audio: undefined},
|
value: { ...listeningPart, audio: undefined },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.listening.instructionsState.customInstructionsURL.startsWith("blob:")) {
|
if (
|
||||||
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL);
|
state.listening.instructionsState.customInstructionsURL.startsWith(
|
||||||
|
"blob:"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(
|
||||||
|
state.listening.instructionsState.customInstructionsURL
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.speaking.sections.forEach((section) => {
|
state.speaking.sections.forEach((section) => {
|
||||||
@@ -145,12 +205,13 @@ export default function Generation({
|
|||||||
sectionId: section.sectionId,
|
sectionId: section.sectionId,
|
||||||
module: "listening",
|
module: "listening",
|
||||||
field: "state",
|
field: "state",
|
||||||
value: {...speakingExercise, video_url: undefined},
|
value: { ...speakingExercise, video_url: undefined },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (sectionState.type === "interactiveSpeaking") {
|
if (sectionState.type === "interactiveSpeaking") {
|
||||||
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
const interactiveSpeaking =
|
||||||
|
sectionState as InteractiveSpeakingExercise;
|
||||||
interactiveSpeaking.prompts.forEach((prompt) => {
|
interactiveSpeaking.prompts.forEach((prompt) => {
|
||||||
URL.revokeObjectURL(prompt.video_url);
|
URL.revokeObjectURL(prompt.video_url);
|
||||||
});
|
});
|
||||||
@@ -162,13 +223,16 @@ export default function Generation({
|
|||||||
field: "state",
|
field: "state",
|
||||||
value: {
|
value: {
|
||||||
...interactiveSpeaking,
|
...interactiveSpeaking,
|
||||||
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})),
|
prompts: interactiveSpeaking.prompts.map((p) => ({
|
||||||
|
...p,
|
||||||
|
video_url: undefined,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dispatch({type: "FULL_RESET"});
|
dispatch({ type: "FULL_RESET" });
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -194,22 +258,25 @@ export default function Generation({
|
|||||||
placeholder="Insert a title here"
|
placeholder="Insert a title here"
|
||||||
name="title"
|
name="title"
|
||||||
label="Title"
|
label="Title"
|
||||||
onChange={(title) => updateRoot({title})}
|
onChange={(title) => updateRoot({ title })}
|
||||||
roundness="xl"
|
roundness="xl"
|
||||||
value={title}
|
value={title}
|
||||||
defaultValue={title}
|
defaultValue={title}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Module
|
||||||
|
</label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={currentModule}
|
value={currentModule}
|
||||||
onChange={(currentModule) => updateRoot({currentModule})}
|
onChange={(currentModule) => updateRoot({ currentModule })}
|
||||||
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between">
|
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
|
||||||
{[...MODULE_ARRAY]
|
>
|
||||||
.filter((m) => permissions[m])
|
{[...MODULE_ARRAY].reduce((acc, x) => {
|
||||||
.map((x) => (
|
if (permissions[x])
|
||||||
|
acc.push(
|
||||||
<Radio value={x} key={x}>
|
<Radio value={x} key={x}>
|
||||||
{({checked}) => (
|
{({ checked }) => (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
@@ -233,16 +300,24 @@ export default function Generation({
|
|||||||
x === "level" &&
|
x === "level" &&
|
||||||
(!checked
|
(!checked
|
||||||
? "bg-white border-mti-gray-platinum"
|
? "bg-white border-mti-gray-platinum"
|
||||||
: "bg-ielts-level/70 border-ielts-level text-white"),
|
: "bg-ielts-level/70 border-ielts-level text-white")
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{capitalize(x)}
|
{capitalize(x)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Radio>
|
</Radio>
|
||||||
))}
|
);
|
||||||
|
return acc;
|
||||||
|
}, [] as JSX.Element[])}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
|
<ExamEditor
|
||||||
|
levelParts={examLevelParts}
|
||||||
|
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
|
||||||
|
entitiesAllowConfExams={entitiesAllowConfExams}
|
||||||
|
entitiesAllowPublicExams={entitiesAllowPublicExams}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -203,18 +203,6 @@ const Training: React.FC<{
|
|||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
<>
|
|
||||||
{isNewContentLoading || areRecordsLoading ? (
|
|
||||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
|
||||||
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
|
||||||
{isNewContentLoading && (
|
|
||||||
<span className="text-center text-2xl font-bold text-mti-green-light">
|
|
||||||
Assessing your exams, please be patient...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RecordFilter
|
<RecordFilter
|
||||||
entities={entities}
|
entities={entities}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -241,12 +229,22 @@ const Training: React.FC<{
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</RecordFilter>
|
</RecordFilter>
|
||||||
|
<>
|
||||||
|
{isNewContentLoading || areRecordsLoading ? (
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||||
|
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||||
|
{isNewContentLoading && (
|
||||||
|
<span className="text-center text-2xl font-bold text-mti-green-light">
|
||||||
|
Assessing your exams, please be patient...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{trainingContent.length == 0 && (
|
{trainingContent.length == 0 && (
|
||||||
<div className="flex flex-grow justify-center items-center">
|
|
||||||
<span className="font-semibold ml-1">
|
<span className="font-semibold ml-1">
|
||||||
No training content to display...
|
No training content to display...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!areRecordsLoading &&
|
{!areRecordsLoading &&
|
||||||
groupedByTrainingContent &&
|
groupedByTrainingContent &&
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ export type RolePermission =
|
|||||||
| "configure_workflows"
|
| "configure_workflows"
|
||||||
| "edit_workflow"
|
| "edit_workflow"
|
||||||
| "delete_workflow"
|
| "delete_workflow"
|
||||||
| "view_confidential_exams";
|
| "view_confidential_exams"
|
||||||
|
| "create_confidential_exams"
|
||||||
|
| "create_public_exams";
|
||||||
|
|
||||||
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
||||||
"view_students",
|
"view_students",
|
||||||
@@ -157,4 +159,6 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
|
|||||||
"view_workflows",
|
"view_workflows",
|
||||||
"edit_workflow",
|
"edit_workflow",
|
||||||
"delete_workflow",
|
"delete_workflow",
|
||||||
|
"create_confidential_exams",
|
||||||
|
"create_public_exams",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user