Compare commits

..

26 Commits

Author SHA1 Message Date
José Lima
25aef3afdf Added new pages and nav with menu 2025-03-07 04:38:57 +00:00
Francisco Lima
df84aaadf4 Merged in limit5SessionsUser (pull request #161)
Implemented limit 5 sessions per User

Approved-by: Tiago Ribeiro
2025-03-05 08:17:05 +00:00
José Lima
2789660e8a Implemented limit 5 sessions per User 2025-03-05 04:42:54 +00:00
Francisco Lima
6c7d189957 Merged in fixStudentPerformanceFreeze (pull request #159)
FixStudentPerformanceFreeze

Approved-by: Tiago Ribeiro
2025-03-04 23:24:17 +00:00
José Lima
31f2a21a76 reverted unnecessary changes 2025-03-04 23:17:20 +00:00
José Lima
c49b1c8070 Fix student performance freeze and search users in create entities
TODO: pagination in student performance freeze
2025-03-04 23:12:26 +00:00
João Correia
655e019bf6 Merged in approval-workflows (pull request #157)
add approved field to exam

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

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

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

Approved-by: Tiago Ribeiro
2025-02-20 14:30:24 +00:00
Joao Correia
b388ee399f small refactor 2025-02-20 12:12:00 +00:00
Joao Correia
4ac11df6ae fix examId being cleared when editing approval workflow 2025-02-20 11:27:44 +00:00
Joao Correia
14e2702aca add error message and stop loading if something went wrong while loading exam in approval workflow 2025-02-20 10:40:31 +00:00
55 changed files with 3110 additions and 2136 deletions

View File

@@ -114,5 +114,6 @@
"husky": "^8.0.3", "husky": "^8.0.3",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.4" "tailwindcss": "^3.2.4"
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,9 @@
import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker";
import SettingsEditor from ".."; import SettingsEditor from "..";
import GenerateBtn from "../Shared/GenerateBtn"; import { ListeningSectionSettings } from "@/stores/examEditor/types";
import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate";
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../../Hooks/useSettingsState"; import useSettingsState from "../../Hooks/useSettingsState";
import { ListeningExam, ListeningPart } from "@/interfaces/exam"; import { ListeningExam, ListeningPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import axios from "axios"; import axios from "axios";
@@ -17,7 +11,6 @@ import { usePersistentExamStore } from "@/stores/exam";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import ListeningComponents from "./components"; import ListeningComponents from "./components";
import { getExamById } from "@/utils/exams";
const ListeningSettings: React.FC = () => { const ListeningSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();

View File

@@ -82,7 +82,7 @@ const ReadingComponents: React.FC<Props> = ({
disabled={generatePassageDisabled} disabled={generatePassageDisabled}
> >
<div <div
className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4 " className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4 "
> >
<div className="flex flex-col flex-grow gap-4 px-2"> <div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">

View File

@@ -12,7 +12,6 @@ import axios from "axios";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import ReadingComponents from "./components"; import ReadingComponents from "./components";
import { getExamById } from "@/utils/exams";
const ReadingSettings: React.FC = () => { const ReadingSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();

View File

@@ -1,11 +1,10 @@
import clsx from "clsx"; import clsx from "clsx";
import SectionRenderer from "./SectionRenderer"; import SectionRenderer from "./SectionRenderer";
import Checkbox from "../Low/Checkbox";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam"; import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { ModuleState, SectionState } from "@/stores/examEditor/types"; import { ModuleState, SectionState } from "@/stores/examEditor/types";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
@@ -21,13 +20,36 @@ import Button from "../Low/Button";
import ResetModule from "./Standalone/ResetModule"; import ResetModule from "./Standalone/ResetModule";
import ListeningInstructions from "./Standalone/ListeningInstructions"; import ListeningInstructions from "./Standalone/ListeningInstructions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Option from "../../interfaces/option";
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; const DIFFICULTIES: Option[] = [
{ value: "A1", label: "A1" },
{ value: "A2", label: "A2" },
{ value: "B1", label: "B1" },
{ value: "B2", label: "B2" },
{ value: "C1", label: "C1" },
{ value: "C2", label: "C2" },
];
const ModuleSettings: Record<Module, React.ComponentType> = {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings,
};
const ExamEditor: React.FC<{ const ExamEditor: React.FC<{
levelParts?: number; levelParts?: number;
entitiesAllowEditPrivacy: EntityWithRoles[]; entitiesAllowEditPrivacy: EntityWithRoles[];
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => { entitiesAllowConfExams: EntityWithRoles[];
entitiesAllowPublicExams: EntityWithRoles[];
}> = ({
levelParts = 0,
entitiesAllowEditPrivacy = [],
entitiesAllowConfExams = [],
entitiesAllowPublicExams = [],
}) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const { const {
sections, sections,
@@ -111,7 +133,10 @@ const ExamEditor: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]); }, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId); const sectionIds = useMemo(
() => sections.map((section) => section.sectionId),
[sections]
);
const updateModule = useCallback( const updateModule = useCallback(
(updates: Partial<ModuleState>) => { (updates: Partial<ModuleState>) => {
@@ -120,29 +145,42 @@ const ExamEditor: React.FC<{
[dispatch] [dispatch]
); );
const toggleSection = (sectionId: number) => { const toggleSection = useCallback(
(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,8 +199,13 @@ 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(
"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 "> <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
@@ -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 />

View File

@@ -9,7 +9,7 @@ import {
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useState } from "react"; import { useState } from "react";
import { BsArrowDown, BsArrowUp } from "react-icons/bs"; import { BsArrowDown, BsArrowUp } from "react-icons/bs";
import Button from "../Low/Button"; import Button from "../Low/Button";

View File

@@ -1,9 +1,7 @@
import {useListSearch} from "@/hooks/useListSearch"; import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table"; import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
import clsx from "clsx"; import clsx from "clsx";
import {useMemo, useState} from "react";
import Button from "./Low/Button";
const SIZE = 25; const SIZE = 25;

View File

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

View File

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

View File

@@ -12,6 +12,10 @@ import {
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsPeople, BsPeople,
BsChevronDown,
BsChevronUp,
BsChatText,
BsCardText,
} from "react-icons/bs"; } from "react-icons/bs";
import { GoWorkflow } from "react-icons/go"; import { GoWorkflow } from "react-icons/go";
import { CiDumbbell } from "react-icons/ci"; import { CiDumbbell } from "react-icons/ci";
@@ -31,7 +35,7 @@ import {
useAllowedEntities, useAllowedEntities,
useAllowedEntitiesSomePermissions, useAllowedEntitiesSomePermissions,
} from "@/hooks/useEntityPermissions"; } from "@/hooks/useEntityPermissions";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { PermissionType } from "../interfaces/permissions"; import { PermissionType } from "../interfaces/permissions";
interface Props { interface Props {
@@ -52,6 +56,7 @@ interface NavProps {
disabled?: boolean; disabled?: boolean;
isMinimized?: boolean; isMinimized?: boolean;
badge?: number; badge?: number;
children?: React.ReactNode;
} }
const Nav = ({ const Nav = ({
@@ -62,8 +67,16 @@ const Nav = ({
disabled = false, disabled = false,
isMinimized = false, isMinimized = false,
badge, badge,
children,
}: NavProps) => { }: NavProps) => {
const [open, setOpen] = useState(false);
return ( return (
<div
className={clsx(
"flex flex-col gap-2 transition-all duration-300 ease-in-out",
open && !isMinimized && "bg-white rounded-xl"
)}
>
<Link <Link
href={!disabled ? keyPath : ""} href={!disabled ? keyPath : ""}
className={clsx( className={clsx(
@@ -89,7 +102,36 @@ const Nav = ({
{badge} {badge}
</div> </div>
)} )}
{children && (
<button
className="flex items-center gap-4 rounded-full p-4 absolute right-0"
onClick={(e) => {
setOpen((prev) => !prev);
e.preventDefault();
}}
>
{open ? (
<BsChevronUp
size={24}
className={clsx(
isMinimized && "hidden",
"transition ease-in-out duration-300"
)}
/>
) : (
<BsChevronDown
size={24}
className={clsx(
isMinimized && "hidden",
"transition ease-in-out duration-300"
)}
/>
)}
</button>
)}
</Link> </Link>
{open || isMinimized ? children : null}
</div>
); );
}; };
@@ -121,12 +163,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 +190,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 +202,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 +277,7 @@ export default function Sidebar({
) && ) &&
entitiesAllowPaymentRecord.length > 0 entitiesAllowPaymentRecord.length > 0
) { ) {
sidebarPermissions["viewPaymentRecord"] = true; sidebarPermissions["viewPaymentRecords"] = true;
} }
return sidebarPermissions; return sidebarPermissions;
}, [ }, [
@@ -325,7 +367,24 @@ export default function Sidebar({
path={path} path={path}
keyPath="/training" keyPath="/training"
isMinimized={isMinimized} isMinimized={isMinimized}
>
<Nav
disabled={disableNavigation}
Icon={BsChatText}
label="Vocabulary"
path={path}
keyPath="/training/vocabulary"
isMinimized={isMinimized}
/> />
<Nav
disabled={disableNavigation}
Icon={BsCardText}
label="Grammar"
path={path}
keyPath="/training/grammar"
isMinimized={isMinimized}
/>
</Nav>
)} )}
{sidebarPermissions["viewPaymentRecords"] && ( {sidebarPermissions["viewPaymentRecords"] && (
<Nav <Nav
@@ -378,7 +437,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
@@ -425,6 +483,33 @@ export default function Sidebar({
path={path} path={path}
keyPath="/training" keyPath="/training"
isMinimized isMinimized
>
<Nav
disabled={disableNavigation}
Icon={BsChatText}
label="Vocabulary"
path={path}
keyPath="/training/vocabulary"
isMinimized
/>
<Nav
disabled={disableNavigation}
Icon={BsCardText}
label="Grammar"
path={path}
keyPath="/training/grammar"
isMinimized
/>
</Nav>
)}
{sidebarPermissions["viewPaymentRecords"] && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized
/> />
)} )}
{sidebarPermissions["viewSettings"] && ( {sidebarPermissions["viewSettings"] && (
@@ -483,7 +568,7 @@ export default function Sidebar({
tabIndex={1} tabIndex={1}
onClick={focusMode ? () => {} : logout} onClick={focusMode ? () => {} : logout}
className={clsx( className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out -xl:px-4",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8" isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)} )}
> >

View File

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

View File

@@ -29,6 +29,7 @@ export interface ExamBase {
access: AccessType; access: AccessType;
label?: string; label?: string;
requiresApproval?: boolean; requiresApproval?: boolean;
approved?: boolean;
} }
export interface ReadingExam extends ExamBase { export interface ReadingExam extends ExamBase {
module: "reading"; module: "reading";
@@ -241,6 +242,7 @@ export interface InteractiveSpeakingExercise extends Section {
} }
export interface FillBlanksMCOption { export interface FillBlanksMCOption {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; id: string;
options: { options: {
A: string; A: string;
@@ -258,6 +260,7 @@ export interface FillBlanksExercise {
text: string; // *EXAMPLE: "They tried to {{1}} burning" text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition?: boolean; allowRepetition?: boolean;
solutions: { solutions: {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve" solution: string; // *EXAMPLE: "preserve"
}[]; }[];
@@ -281,6 +284,7 @@ export interface TrueFalseExercise {
} }
export interface TrueFalseQuestion { export interface TrueFalseQuestion {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?" prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True" solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True"
@@ -293,6 +297,7 @@ export interface WriteBlanksExercise {
id: string; id: string;
text: string; // *EXAMPLE: "The Government plans to give ${{14}}" text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
solutions: { solutions: {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "14" id: string; // *EXAMPLE: "14"
solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive) solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive)
}[]; }[];
@@ -319,12 +324,14 @@ export interface MatchSentencesExercise {
} }
export interface MatchSentenceExerciseSentence { export interface MatchSentenceExerciseSentence {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; id: string;
sentence: string; sentence: string;
solution: string; solution: string;
} }
export interface MatchSentenceExerciseOption { export interface MatchSentenceExerciseOption {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; id: string;
sentence: string; sentence: string;
} }
@@ -346,6 +353,7 @@ export interface MultipleChoiceExercise {
export interface MultipleChoiceQuestion { export interface MultipleChoiceQuestion {
variant: "image" | "text"; variant: "image" | "text";
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?" prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: string; // *EXAMPLE: "A" solution: string; // *EXAMPLE: "A"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,15 @@ 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";
@@ -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,21 +68,35 @@ 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 }),
@@ -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,9 +123,16 @@ 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={[
@@ -96,7 +142,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
{ 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) =>
setUnit((value?.value as DurationUnit) || "months")
}
value={{ value: unit, label: capitalize(unit) }} value={{ value: unit, label: capitalize(unit) }}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
styles={{ styles={{
@@ -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>
@@ -137,7 +198,8 @@ export default function PackageList({user}: {user: User}) {
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(),
@@ -186,13 +251,21 @@ export default function PackageList({user}: {user: User}) {
cell: ({ row }: { row: { original: Package } }) => { cell: ({ row }: { row: { original: Package } }) => {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{["developer", "admin"].includes(user.type) && ( {["developer", "admin"].includes(user?.type) && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}> <div
data-tip="Edit"
className="cursor-pointer tooltip"
onClick={() => setEditingPackage(row.original)}
>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
{["developer", "admin"].includes(user.type) && ( {["developer", "admin"].includes(user?.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}> <div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deletePackage(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -200,7 +273,9 @@ export default function PackageList({user}: {user: User}) {
); );
}, },
}, },
]; ],
[deletePackage, user]
);
const table = useReactTable({ const table = useReactTable({
data: packages, data: packages,
@@ -208,18 +283,19 @@ export default function PackageList({user}: {user: User}) {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const closeModal = () => { const closeModal = useCallback(() => {
setIsCreating(false); setIsCreating(false);
setEditingPackage(undefined); setEditingPackage(undefined);
reload(); reload();
}; }, [reload]);
return ( return (
<div className="w-full h-full rounded-xl"> <div className="w-full h-full rounded-xl">
<Modal <Modal
isOpen={isCreating || !!editingPackage} isOpen={isCreating || !!editingPackage}
onClose={closeModal} onClose={closeModal}
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}> title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
>
<PackageCreator onClose={closeModal} pack={editingPackage} /> <PackageCreator onClose={closeModal} pack={editingPackage} />
</Modal> </Modal>
<table className="bg-mti-purple-ultralight/40 w-full"> <table className="bg-mti-purple-ultralight/40 w-full">
@@ -228,7 +304,12 @@ export default function PackageList({user}: {user: User}) {
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}> <th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -236,7 +317,10 @@ export default function PackageList({user}: {user: User}) {
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> <tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}> <td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -248,7 +332,8 @@ export default function PackageList({user}: {user: User}) {
</table> </table>
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"> className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
>
New Package New Package
</button> </button>
</div> </div>

View File

@@ -5,12 +5,19 @@ 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;
userStats: Stat[];
};
const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceItem[]; stats: Stat[]}) => { const StudentPerformanceList = ({
items = [],
}: {
items: StudentPerformanceItem[];
}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false); const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>(); const columnHelper = createColumnHelper<StudentPerformanceItem>();
@@ -41,46 +48,86 @@ const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceI
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`, : `${
Object.keys(
groupByExam(
info.row.original.userStats.filter(
(x) => x.module === "reading"
)
)
).length
} exams`,
}), }),
columnHelper.accessor("levels.listening", { columnHelper.accessor("levels.listening", {
header: "Listening", header: "Listening",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`, : `${
Object.keys(
groupByExam(
info.row.original.userStats.filter(
(x) => x.module === "listening"
)
)
).length
} exams`,
}), }),
columnHelper.accessor("levels.writing", { columnHelper.accessor("levels.writing", {
header: "Writing", header: "Writing",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`, : `${
Object.keys(
groupByExam(
info.row.original.userStats.filter(
(x) => x.module === "writing"
)
)
).length
} exams`,
}), }),
columnHelper.accessor("levels.speaking", { columnHelper.accessor("levels.speaking", {
header: "Speaking", header: "Speaking",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`, : `${
Object.keys(
groupByExam(
info.row.original.userStats.filter(
(x) => x.module === "speaking"
)
)
).length
} exams`,
}), }),
columnHelper.accessor("levels.level", { columnHelper.accessor("levels.level", {
header: "Level", header: "Level",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`, : `${
Object.keys(
groupByExam(
info.row.original.userStats.filter(
(x) => x.module === "level"
)
)
).length
} exams`,
}), }),
columnHelper.accessor("levels", { columnHelper.accessor("userStats", {
id: "overall_level", id: "overall_level",
header: "Overall", header: "Overall",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? averageLevelCalculator( ? averageLevelCalculator(
items, info.row.original.focus,
stats.filter((x) => x.user === info.row.original.id), info.getValue()
).toFixed(1) ).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`, : `${Object.keys(groupByExam(info.getValue())).length} exams`,
}), }),
]; ];
@@ -92,17 +139,17 @@ const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceI
<Table<StudentPerformanceItem> <Table<StudentPerformanceItem>
data={items.sort( data={items.sort(
(a, b) => (a, b) =>
averageLevelCalculator( averageLevelCalculator(b.focus, b.userStats) -
items, averageLevelCalculator(a.focus, a.userStats)
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
items,
stats.filter((x) => x.user === a.id),
),
)} )}
columns={columns} columns={columns}
searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]} searchFields={[
["name"],
["email"],
["studentID"],
["entitiesLabel"],
["group"],
]}
/> />
</div> </div>
); );

View File

@@ -5,7 +5,7 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
BsCheck, BsCheck,
BsCheckCircle, BsCheckCircle,

View File

@@ -1,28 +1,20 @@
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[] };
@@ -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);
@@ -91,11 +106,16 @@ export default function UserCreator({user, users, entities = [], permissions, on
}, [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,14 +236,26 @@ 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={{
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"])}
@@ -193,13 +263,24 @@ export default function UserCreator({user, users, entities = [], permissions, on
</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>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; import { Exam, ExamBase, InstructorGender, LevelExam, ListeningExam, ReadingExam, SpeakingExam, Variant } from "@/interfaces/exam";
import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation"; import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
@@ -10,6 +10,7 @@ import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/a
import { generateExamDifferences } from "@/utils/exam.differences"; import { generateExamDifferences } from "@/utils/exam.differences";
import { getExams } from "@/utils/exams.be"; import { getExams } from "@/utils/exams.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { uuidv4 } from "@firebase/util";
import { access } from "fs"; import { access } from "fs";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@@ -18,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;

View File

@@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { flatten, map } from "lodash"; import { flatten } from "lodash";
import { AccessType, Exam } from "@/interfaces/exam"; import { AccessType, Exam } from "@/interfaces/exam";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { requestUser } from "../../../utils/api"; import { requestUser } from "../../../utils/api";

View File

@@ -48,4 +48,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
await db.collection("sessions").updateOne({ id: session.id }, { $set: session }, { upsert: true }); await db.collection("sessions").updateOne({ id: session.id }, { $set: session }, { upsert: true });
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
const sessions = await db.collection("sessions").find<Session>({ user: session.user }, { projection: { id: 1 } }).sort({ date: 1 }).toArray();
// Delete old sessions
if (sessions.length > 5) {
await db.collection("sessions").deleteOne({ id: { $in: sessions.slice(0, sessions.length - 5).map(x => x.id) } });
}
} }

View File

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

View File

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

View File

@@ -150,7 +150,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const handleApproveStep = () => { const handleApproveStep = () => {
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length); const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
if (isLastStep) { if (isLastStep) {
if (!confirm(`Are you sure you want to approve the last step? Doing so will change the access type of the exam from confidential to private.`)) return; if (!confirm(`Are you sure you want to approve the last step and complete the approval process?`)) return;
} }
const updatedWorkflow: ApprovalWorkflow = { const updatedWorkflow: ApprovalWorkflow = {
@@ -192,7 +192,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const examId = currentWorkflow.examId; const examId = currentWorkflow.examId;
axios axios
.patch(`/api/exam/${examModule}/${examId}`, { access: "private" }) .patch(`/api/exam/${examModule}/${examId}`, { approved: true })
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`)) .then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
@@ -260,10 +260,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
if (examModule && examId) { if (examModule && examId) {
const exam = await getExamById(examModule, examId.trim()); const exam = await getExamById(examModule, examId.trim());
if (!exam) { if (!exam) {
toast.error( toast.error("Something went wrong while fetching exam!");
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{ toastId: "invalid-exam-id" }
);
setViewExamIsLoading(false); setViewExamIsLoading(false);
return; return;
} }
@@ -389,7 +386,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
{/* Side panel */} {/* Side panel */}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<LayoutGroup key="sidePanel"> <LayoutGroup key="sidePanel">
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}> <section className={`absolute inset-y-0 right-0 h-full overflow-y-auto bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
{isPanelOpen && selectedStep && ( {isPanelOpen && selectedStep && (
<motion.div <motion.div
className="p-6" className="p-6"
@@ -554,12 +551,16 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="overflow-hidden mt-2" className="overflow-hidden mt-2"
> >
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-40"> <div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-[300px]">
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? ( {currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => ( currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
<>
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2"> <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

View File

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

View File

@@ -70,7 +70,7 @@ export default function Home({ user, users }: Props) {
const [licenses, setLicenses] = useState(0); const [licenses, setLicenses] = useState(0);
const { rows, renderSearch } = useListSearch<User>( const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]], [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]],
users users
); );
const { items, renderMinimal } = usePagination<User>(rows, 16); const { items, renderMinimal } = usePagination<User>(rows, 16);

View File

@@ -9,7 +9,11 @@ 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 {
findAllowedEntities,
findAllowedEntitiesSomePermissions,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { User } from "@/interfaces/user"; 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";
@@ -18,7 +22,13 @@ 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 {
Exam,
Exercise,
InteractiveSpeakingExercise,
ListeningPart,
SpeakingExercise,
} from "@/interfaces/exam";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
@@ -27,29 +37,56 @@ 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 {
id?: string;
module?: Module;
};
if (!id || !examModule) return { props: serialize({ user, permissions }) }; 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,9 +126,13 @@ 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 } });
@@ -130,8 +184,14 @@ export default function Generation({
} }
}); });
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) => {
@@ -150,7 +210,8 @@ export default function Generation({
}); });
} }
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,7 +223,10 @@ 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,
})),
}, },
}, },
}); });
@@ -200,14 +264,17 @@ export default function Generation({
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
@@ -233,16 +300,24 @@ export default function Generation({
x === "level" && x === "level" &&
(!checked (!checked
? "bg-white border-mti-gray-platinum" ? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white"), : "bg-ielts-level/70 border-ielts-level text-white")
)}> )}
>
{capitalize(x)} {capitalize(x)}
</span> </span>
)} )}
</Radio> </Radio>
))} );
return acc;
}, [] as JSX.Element[])}
</RadioGroup> </RadioGroup>
</div> </div>
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} /> <ExamEditor
levelParts={examLevelParts}
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
entitiesAllowConfExams={entitiesAllowConfExams}
entitiesAllowPublicExams={entitiesAllowPublicExams}
/>
</> </>
)} )}
</> </>

View File

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

View File

@@ -0,0 +1,41 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { User } from "@/interfaces/user";
import { ToastContainer } from "react-toastify";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/");
return {
props: serialize({ user }),
};
}, sessionOptions);
const Grammar: React.FC<{
user: User;
}> = ({ user }) => {
return (
<>
<Head>
<title>Training | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
</>
);
};
export default Grammar;

View File

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

View File

@@ -0,0 +1,41 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { User } from "@/interfaces/user";
import { ToastContainer } from "react-toastify";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/");
return {
props: serialize({ user }),
};
}, sessionOptions);
const Vocabulary: React.FC<{
user: User;
}> = ({ user }) => {
return (
<>
<Head>
<title>Training | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
</>
);
};
export default Vocabulary;

View File

@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { BsChevronLeft } from "react-icons/bs"; import { BsChevronLeft } from "react-icons/bs";
import { mapBy, serialize } from "@/utils"; import { mapBy, serialize } from "@/utils";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getUsersWithStats } from "@/utils/users.be";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -30,12 +30,35 @@ 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"])
? getUsers({ type: "student" }) ? getUsersWithStats(
: getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" })); { type: "student" },
{
id: 1,
entities: 1,
focus: 1,
email: 1,
name: 1,
levels: 1,
userStats: 1,
studentID: 1,
}
)
: getUsersWithStats(
{ type: "student", "entities.id": { in: mapBy(entities, "id") } },
{
id: 1,
entities: 1,
focus: 1,
email: 1,
name: 1,
levels: 1,
userStats: 1,
studentID: 1,
}
));
const groups = await getParticipantsGroups(mapBy(students, "id")); const groups = await getParticipantsGroups(mapBy(students, "id"));
return { return {
@@ -45,23 +68,22 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
interface Props { interface Props {
user: User; user: User;
students: StudentUser[]; students: (StudentUser & { userStats: Stat[] })[];
entities: Entity[]; entities: Entity[];
groups: Group[]; groups: Group[];
} }
const StudentPerformance = ({ user, students, entities, groups }: Props) => { const StudentPerformance = ({ students, entities, groups }: Props) => {
const { data: stats } = useFilterRecordsByUser<Stat[]>();
const router = useRouter(); const router = useRouter();
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 (
@@ -76,7 +98,6 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -91,7 +112,7 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
Student Performance ({students.length}) Student Performance ({students.length})
</h2> </h2>
</div> </div>
<StudentPerformanceList items={performanceStudents} stats={stats} /> <StudentPerformanceList items={performanceStudents} />
</> </>
</> </>
); );

View File

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

View File

@@ -4,7 +4,7 @@ import { ObjectId } from "mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[]) => { export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[], assignee?: string) => {
const filters: any = {}; const filters: any = {};
if (ids && ids.length > 0) { if (ids && ids.length > 0) {
@@ -15,7 +15,15 @@ export const getApprovalWorkflows = async (collection: string, entityIds?: strin
filters.entityId = { $in: entityIds }; filters.entityId = { $in: entityIds };
} }
return await db.collection<ApprovalWorkflow>(collection).find(filters).toArray(); if (assignee) {
filters["steps.assignees"] = assignee;
}
return await db
.collection<ApprovalWorkflow>(collection)
.find(filters)
.sort({ startDate: -1 })
.toArray();
}; };
export const getApprovalWorkflow = async (collection: string, id: string) => { export const getApprovalWorkflow = async (collection: string, id: string) => {
@@ -26,6 +34,7 @@ export const getApprovalWorkflowsByEntities = async (collection: string, ids: st
return await db return await db
.collection<ApprovalWorkflow>(collection) .collection<ApprovalWorkflow>(collection)
.find({ entityId: { $in: ids } }) .find({ entityId: { $in: ids } })
.sort({ startDate: -1 })
.toArray(); .toArray();
}; };

View File

@@ -1,7 +1,6 @@
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { diff, Diff } from "deep-diff";
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]); const EXCLUDED_KEYS = new Set<string>(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]);
const PATH_LABELS: Record<string, string> = { const PATH_LABELS: Record<string, string> = {
access: "Access Type", access: "Access Type",
@@ -24,124 +23,146 @@ const PATH_LABELS: Record<string, string> = {
allowRepetition: "Allow Repetition", allowRepetition: "Allow Repetition",
maxWords: "Max Words", maxWords: "Max Words",
minTimer: "Timer", minTimer: "Timer",
section: "Section",
module: "Module",
type: "Type",
intro: "Intro",
category: "Category",
context: "Context",
instructions: "Instructions",
name: "Name",
gender: "Gender",
voice: "Voice",
enableNavigation: "Enable Navigation",
limit: "Limit",
instructorGender: "Instructor Gender",
wordCounter: "Word Counter",
attachment: "Attachment",
first_title: "First Title",
second_title: "Second Title",
first_topic: "First Topic",
second_topic: "Second Topic",
questions: "Questions",
sentences: "Sentences",
sentence: "Sentence",
solution: "Solution",
passage: "Passage",
};
const ARRAY_ITEM_LABELS: Record<string, string> = {
exercises: "Exercise",
paths: "Path",
difficulties: "Difficulty",
solutions: "Solution",
options: "Option",
words: "Word",
questions: "Question",
userSolutions: "User Solution",
sentences: "Sentence",
parts: "Part",
}; };
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] { export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
const differences = diff(oldExam, newExam) || []; const differences: string[] = [];
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[]; compareObjects(oldExam, newExam, [], differences);
return differences;
} }
function formatDifference(change: Diff<any, any>): string | undefined { function isObject(val: any): val is Record<string, any> {
if (!change.path) return; return val !== null && typeof val === "object" && !Array.isArray(val);
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return;
} }
const pathString = pathToHumanReadable(change.path); function formatPrimitive(value: any): string {
switch (change.kind) {
case "N": // New property/element
return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`;
case "D": // Deleted property/element
return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`;
case "E": // Edited property/element
return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`;
case "A": // Array change
return formatArrayChange(change);
default:
return;
}
}
function formatArrayChange(change: Diff<any, any>): string | undefined {
if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return;
}
const pathString = pathToHumanReadable(change.path);
const arrayChange = (change as any).item;
const idx = (change as any).index;
if (!arrayChange) return;
switch (arrayChange.kind) {
case "N":
return `• Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`;
case "D":
return `• Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`;
case "E":
return `• Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`;
case "A":
return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`;
default:
return;
}
}
function formatValue(value: any): string {
if (value === null) return "null";
if (value === undefined) return "undefined"; if (value === undefined) return "undefined";
if (value === null) return "null";
if (typeof value === "object") {
try {
const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS);
const renamed = renameKeysDeep(sanitized, PATH_LABELS);
return JSON.stringify(renamed, null, 2);
} catch {
return String(value);
}
}
return JSON.stringify(value); return JSON.stringify(value);
} }
function removeExcludedKeysDeep(obj: any, excludedKeys: Set<string>): any {
if (Array.isArray(obj)) {
return obj.map((item) => removeExcludedKeysDeep(item, excludedKeys));
} else if (obj && typeof obj === "object") {
const newObj: any = {};
for (const key of Object.keys(obj)) {
if (excludedKeys.has(key)) {
// Skip this key entirely
continue;
}
newObj[key] = removeExcludedKeysDeep(obj[key], excludedKeys);
}
return newObj;
}
return obj;
}
function renameKeysDeep(obj: any, renameMap: Record<string, string>): any {
if (Array.isArray(obj)) {
return obj.map((item) => renameKeysDeep(item, renameMap));
} else if (obj && typeof obj === "object") {
const newObj: any = {};
for (const key of Object.keys(obj)) {
const newKey = renameMap[key] ?? key; // Use friendly label if available
newObj[newKey] = renameKeysDeep(obj[key], renameMap);
}
return newObj;
}
return obj;
}
/**
* Convert an array of path segments into a user-friendly string.
* e.g. ["parts", 0, "exercises", 1, "prompt"]
* → "Parts → [#1] → Exercises → [#2] → Prompt"
*/
function pathToHumanReadable(pathSegments: Array<string | number>): string { function pathToHumanReadable(pathSegments: Array<string | number>): string {
return pathSegments const mapped = pathSegments.map((seg) => {
.map((seg) => {
if (typeof seg === "number") { if (typeof seg === "number") {
return `[#${seg + 1}]`; return `#${seg + 1}`;
} }
return PATH_LABELS[seg] ?? seg; return PATH_LABELS[seg] ?? seg;
}) });
.join(" → ");
let result = "";
for (let i = 0; i < mapped.length; i++) {
result += mapped[i];
if (mapped[i].startsWith("#") && i < mapped.length - 1) {
result += " - ";
} else if (i < mapped.length - 1) {
result += " ";
}
}
return result.trim();
}
function getArrayItemLabel(path: (string | number)[]): string {
if (path.length === 0) return "item";
const lastSegment = path[path.length - 1];
if (typeof lastSegment === "string" && ARRAY_ITEM_LABELS[lastSegment]) {
return ARRAY_ITEM_LABELS[lastSegment];
}
return "item";
}
function getIdentifier(item: any): string | number | undefined {
if (item?.uuid !== undefined) return item.uuid;
if (item?.id !== undefined) return item.id;
return undefined;
}
function compareObjects(oldObj: any, newObj: any, path: (string | number)[], differences: string[]) {
if (Array.isArray(oldObj) && Array.isArray(newObj)) {
// Check if array elements are objects with an identifier (uuid or id).
if (oldObj.length > 0 && typeof oldObj[0] === "object" && getIdentifier(oldObj[0]) !== undefined) {
// Process removed items
const newIds = new Set(newObj.map((item: any) => getIdentifier(item)));
for (let i = 0; i < oldObj.length; i++) {
const oldItem = oldObj[i];
const identifier = getIdentifier(oldItem);
if (identifier !== undefined && !newIds.has(identifier)) {
differences.push(`• Removed ${getArrayItemLabel(path)} #${i + 1} from ${pathToHumanReadable(path)}\n`);
}
}
const oldIndexMap = new Map(oldObj.map((item: any, index: number) => [getIdentifier(item), index]));
// Process items in the new array using their order.
for (let i = 0; i < newObj.length; i++) {
const newItem = newObj[i];
const identifier = getIdentifier(newItem);
if (identifier !== undefined) {
if (oldIndexMap.has(identifier)) {
const oldIndex = oldIndexMap.get(identifier)!;
const oldItem = oldObj[oldIndex];
compareObjects(oldItem, newItem, path.concat(`#${i + 1}`), differences);
} else {
differences.push(`• Added new ${getArrayItemLabel(path)} #${i + 1} at ${pathToHumanReadable(path)}\n`);
}
} else {
// Fallback: if item does not have an identifier, compare by index.
compareObjects(oldObj[i], newItem, path.concat(`#${i + 1}`), differences);
}
}
} else {
// For arrays that are not identifier-based, compare element by element.
const maxLength = Math.max(oldObj.length, newObj.length);
for (let i = 0; i < maxLength; i++) {
compareObjects(oldObj[i], newObj[i], path.concat(`#${i + 1}`), differences);
}
}
} else if (isObject(oldObj) && isObject(newObj)) {
// Compare objects by keys (ignoring excluded keys).
const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
for (const key of keys) {
if (EXCLUDED_KEYS.has(key)) continue;
compareObjects(oldObj[key], newObj[key], path.concat(key), differences);
}
} else {
if (oldObj !== newObj) {
differences.push(`• Changed ${pathToHumanReadable(path)} from:\n ${formatPrimitive(oldObj)}\n To:\n ${formatPrimitive(newObj)}\n`);
}
}
} }

View File

@@ -70,9 +70,9 @@ export const getExamById = async (module: Module, id: string): Promise<Exam | un
export const defaultExamUserSolutions = (exam: Exam) => { export const defaultExamUserSolutions = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") if (exam.module === "reading" || exam.module === "listening" || exam.module === "level")
return exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)); return (exam.parts.flatMap((x) => x.exercises) ?? []).map((x) => defaultUserSolutions(x, exam));
return exam.exercises.map((x) => defaultUserSolutions(x, exam)); return (exam.exercises ?? []).map((x) => defaultUserSolutions(x, exam));
}; };
export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => { export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => {

View File

@@ -193,17 +193,18 @@ export const getGradingLabel = (score: number, grading: Step[]) => {
return "N/A"; return "N/A";
}; };
export const averageLevelCalculator = (users: User[], studentStats: Stat[]) => { export const averageLevelCalculator = (focus: Type, studentStats: Stat[]) => {
const formattedStats = studentStats /* const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus, focus: focus,
score: s.score, score: s.score,
module: s.module, module: s.module,
})) }))
.filter((f) => !!f.focus); .filter((f) => !!f.focus); */
const bandScores = formattedStats.map((s) => ({
const bandScores = studentStats.map((s) => ({
module: s.module, module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), level: calculateBandScore(s.score.correct, s.score.total, s.module, focus),
})); }));
const levels: { [key in Module]: number } = { const levels: { [key in Module]: number } = {

View File

@@ -6,7 +6,7 @@ import client from "@/lib/mongodb";
import { EntityWithRoles, WithEntities } from "@/interfaces/entity"; import { EntityWithRoles, WithEntities } from "@/interfaces/entity";
import { getEntity } from "./entities.be"; import { getEntity } from "./entities.be";
import { getRole } from "./roles.be"; import { getRole } from "./roles.be";
import { findAllowedEntities, groupAllowedEntitiesByPermissions } from "./permissions"; import { groupAllowedEntitiesByPermissions } from "./permissions";
import { mapBy } from "."; import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -20,6 +20,15 @@ export async function getUsers(filter?: object, limit = 0, sort = {}, projection
.toArray(); .toArray();
} }
export async function getUsersWithStats(filter?: object, projection = {}, limit = 0, sort = {}) {
return await db
.collection("usersWithStats")
.find<User>(filter || {}, { projection: { _id: 0, ...projection } })
.limit(limit)
.sort(sort)
.toArray();
}
export async function searchUsers(searchInput?: string, limit = 50, page = 0, sort: object = { "name": 1 }, projection = {}, filter?: object) { export async function searchUsers(searchInput?: string, limit = 50, page = 0, sort: object = { "name": 1 }, projection = {}, filter?: object) {
const compoundFilter = { const compoundFilter = {
"compound": { "compound": {
@@ -266,12 +275,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 }
} }