Merged develop into approval-workflows
This commit is contained in:
@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
|
|||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4">
|
<div className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4">
|
||||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import Dropdown from "../Shared/SettingsDropdown";
|
|
||||||
import ExercisePicker from "../../ExercisePicker";
|
|
||||||
import SettingsEditor from "..";
|
import SettingsEditor from "..";
|
||||||
import GenerateBtn from "../Shared/GenerateBtn";
|
import { ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { generate } from "../Shared/Generate";
|
|
||||||
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
|
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import useSettingsState from "../../Hooks/useSettingsState";
|
import useSettingsState from "../../Hooks/useSettingsState";
|
||||||
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -17,7 +11,6 @@ import { usePersistentExamStore } from "@/stores/exam";
|
|||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ListeningComponents from "./components";
|
import ListeningComponents from "./components";
|
||||||
import { getExamById } from "@/utils/exams";
|
|
||||||
|
|
||||||
const ListeningSettings: React.FC = () => {
|
const ListeningSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const ReadingComponents: React.FC<Props> = ({
|
|||||||
disabled={generatePassageDisabled}
|
disabled={generatePassageDisabled}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4 "
|
className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4 "
|
||||||
>
|
>
|
||||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import axios from "axios";
|
|||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ReadingComponents from "./components";
|
import ReadingComponents from "./components";
|
||||||
import { getExamById } from "@/utils/exams";
|
|
||||||
|
|
||||||
const ReadingSettings: React.FC = () => {
|
const ReadingSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SectionRenderer from "./SectionRenderer";
|
import SectionRenderer from "./SectionRenderer";
|
||||||
import Checkbox from "../Low/Checkbox";
|
|
||||||
import Input from "../Low/Input";
|
import Input from "../Low/Input";
|
||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
@@ -21,13 +20,36 @@ import Button from "../Low/Button";
|
|||||||
import ResetModule from "./Standalone/ResetModule";
|
import ResetModule from "./Standalone/ResetModule";
|
||||||
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import Option from "../../interfaces/option";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
const DIFFICULTIES: Option[] = [
|
||||||
|
{ value: "A1", label: "A1" },
|
||||||
|
{ value: "A2", label: "A2" },
|
||||||
|
{ value: "B1", label: "B1" },
|
||||||
|
{ value: "B2", label: "B2" },
|
||||||
|
{ value: "C1", label: "C1" },
|
||||||
|
{ value: "C2", label: "C2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||||
|
reading: ReadingSettings,
|
||||||
|
writing: WritingSettings,
|
||||||
|
speaking: SpeakingSettings,
|
||||||
|
listening: ListeningSettings,
|
||||||
|
level: LevelSettings,
|
||||||
|
};
|
||||||
|
|
||||||
const ExamEditor: React.FC<{
|
const ExamEditor: React.FC<{
|
||||||
levelParts?: number;
|
levelParts?: number;
|
||||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||||
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => {
|
entitiesAllowConfExams: EntityWithRoles[];
|
||||||
|
entitiesAllowPublicExams: EntityWithRoles[];
|
||||||
|
}> = ({
|
||||||
|
levelParts = 0,
|
||||||
|
entitiesAllowEditPrivacy = [],
|
||||||
|
entitiesAllowConfExams = [],
|
||||||
|
entitiesAllowPublicExams = [],
|
||||||
|
}) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
const {
|
const {
|
||||||
sections,
|
sections,
|
||||||
@@ -111,7 +133,10 @@ const ExamEditor: React.FC<{
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [numberOfLevelParts]);
|
}, [numberOfLevelParts]);
|
||||||
|
|
||||||
const sectionIds = sections.map((section) => section.sectionId);
|
const sectionIds = useMemo(
|
||||||
|
() => sections.map((section) => section.sectionId),
|
||||||
|
[sections]
|
||||||
|
);
|
||||||
|
|
||||||
const updateModule = useCallback(
|
const updateModule = useCallback(
|
||||||
(updates: Partial<ModuleState>) => {
|
(updates: Partial<ModuleState>) => {
|
||||||
@@ -120,29 +145,42 @@ const ExamEditor: React.FC<{
|
|||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleSection = (sectionId: number) => {
|
const toggleSection = useCallback(
|
||||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
(sectionId: number) => {
|
||||||
toast.error("Include at least one section!");
|
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||||
return;
|
toast.error("Include at least one section!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
||||||
|
},
|
||||||
|
[dispatch, expandedSections, sectionIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const Settings = useMemo(
|
||||||
|
() => ModuleSettings[currentModule],
|
||||||
|
[currentModule]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showImport = useMemo(
|
||||||
|
() =>
|
||||||
|
importModule && ["reading", "listening", "level"].includes(currentModule),
|
||||||
|
[importModule, currentModule]
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessTypeOptions = useMemo(() => {
|
||||||
|
let options: Option[] = [{ value: "private", label: "Private" }];
|
||||||
|
if (entitiesAllowConfExams.length > 0) {
|
||||||
|
options.push({ value: "confidential", label: "Confidential" });
|
||||||
}
|
}
|
||||||
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
if (entitiesAllowPublicExams.length > 0) {
|
||||||
};
|
options.push({ value: "public", label: "Public" });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
|
||||||
|
|
||||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
const updateLevelParts = useCallback((parts: number) => {
|
||||||
reading: ReadingSettings,
|
|
||||||
writing: WritingSettings,
|
|
||||||
speaking: SpeakingSettings,
|
|
||||||
listening: ListeningSettings,
|
|
||||||
level: LevelSettings,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Settings = ModuleSettings[currentModule];
|
|
||||||
const showImport =
|
|
||||||
importModule && ["reading", "listening", "level"].includes(currentModule);
|
|
||||||
|
|
||||||
const updateLevelParts = (parts: number) => {
|
|
||||||
setNumberOfLevelParts(parts);
|
setNumberOfLevelParts(parts);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -161,9 +199,14 @@ const ExamEditor: React.FC<{
|
|||||||
setNumberOfLevelParts={setNumberOfLevelParts}
|
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-4 w-full items-center -xl:flex-col">
|
<div
|
||||||
<div className="flex flex-row gap-3 w-full">
|
className={clsx(
|
||||||
<div className="flex flex-col gap-3">
|
"flex gap-4 w-full",
|
||||||
|
sectionLabels.length > 3 ? "-2xl:flex-col" : "-xl:flex-col"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row gap-3">
|
||||||
|
<div className="flex flex-col gap-3 ">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
Timer
|
Timer
|
||||||
</label>
|
</label>
|
||||||
@@ -176,19 +219,16 @@ const ExamEditor: React.FC<{
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
value={minTimer}
|
value={minTimer}
|
||||||
className="max-w-[300px]"
|
className="max-w-[125px] min-w-[100px] w-min"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 flex-grow">
|
<div className="flex flex-col gap-3 ">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
Difficulty
|
Difficulty
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
isMulti={true}
|
isMulti={true}
|
||||||
options={DIFFICULTIES.map((x) => ({
|
options={DIFFICULTIES}
|
||||||
value: x,
|
|
||||||
label: capitalize(x),
|
|
||||||
}))}
|
|
||||||
onChange={(values) => {
|
onChange={(values) => {
|
||||||
const selectedDifficulties = values
|
const selectedDifficulties = values
|
||||||
? values.map((v) => v.value as Difficulty)
|
? values.map((v) => v.value as Difficulty)
|
||||||
@@ -214,12 +254,12 @@ const ExamEditor: React.FC<{
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
{sectionLabels[0].label.split(" ")[0]}
|
{sectionLabels[0].label.split(" ")[0]}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-row gap-8">
|
<div className="flex flex-row gap-3">
|
||||||
{sectionLabels.map(({ id, label }) => (
|
{sectionLabels.map(({ id, label }) => (
|
||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
sectionIds.includes(id)
|
sectionIds.includes(id)
|
||||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||||
@@ -246,22 +286,24 @@ const ExamEditor: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="max-w-[200px] w-full">
|
||||||
<div className="flex flex-row gap-3 w-64">
|
<Select
|
||||||
<Select
|
label="Access Type"
|
||||||
label="Access Type"
|
disabled={
|
||||||
options={ACCESSTYPE.map((item) => ({
|
accessTypeOptions.length === 0 ||
|
||||||
value: item,
|
entitiesAllowEditPrivacy.length === 0
|
||||||
label: capitalize(item),
|
|
||||||
}))}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (value?.value) {
|
|
||||||
updateModule({ access: value.value! as AccessType });
|
|
||||||
}
|
}
|
||||||
}}
|
options={accessTypeOptions}
|
||||||
value={{ value: access, label: capitalize(access) }}
|
onChange={(value) => {
|
||||||
/>
|
if (value?.value) {
|
||||||
|
updateModule({ access: value.value! as AccessType });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={{ value: access, label: capitalize(access) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-3 w-full">
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<div className="flex flex-col gap-3 flex-grow">
|
<div className="flex flex-col gap-3 flex-grow">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
@@ -286,7 +328,7 @@ const ExamEditor: React.FC<{
|
|||||||
Reset Module
|
Reset Module
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-8 -2xl:flex-col">
|
<div className="flex flex-row gap-8 -xl:flex-col">
|
||||||
<Settings />
|
<Settings />
|
||||||
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||||
<SectionRenderer />
|
<SectionRenderer />
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
import usePagination from "@/hooks/usePagination";
|
import usePagination from "@/hooks/usePagination";
|
||||||
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useMemo, useState} from "react";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
|
|
||||||
const SIZE = 25;
|
const SIZE = 25;
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { checkAccess } from "@/utils/permissions";
|
|||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import useRecordStore from "@/stores/recordStore";
|
import useRecordStore from "@/stores/recordStore";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { mapBy } from "@/utils";
|
import { mapBy } from "@/utils";
|
||||||
@@ -44,13 +42,13 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
|
|
||||||
const [entity, setEntity] = useState<string>();
|
const [entity, setEntity] = useState<string>();
|
||||||
|
|
||||||
const [, setStatsUserId] = useRecordStore((state) => [
|
const [selectedUser, setStatsUserId] = useRecordStore((state) => [
|
||||||
state.selectedUser,
|
state.selectedUser,
|
||||||
state.setSelectedUser,
|
state.setSelectedUser,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const entitiesToSearch = useMemo(() => {
|
const entitiesToSearch = useMemo(() => {
|
||||||
if(entity) return entity
|
if (entity) return entity;
|
||||||
if (isAdmin) return undefined;
|
if (isAdmin) return undefined;
|
||||||
return mapBy(entities, "id");
|
return mapBy(entities, "id");
|
||||||
}, [entities, entity, isAdmin]);
|
}, [entities, entity, isAdmin]);
|
||||||
@@ -68,7 +66,15 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
entities,
|
entities,
|
||||||
"view_student_record"
|
"view_student_record"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedUserValue = useMemo(
|
||||||
|
() =>
|
||||||
|
users.find((u) => u.id === selectedUser) || {
|
||||||
|
value: user.id,
|
||||||
|
label: `${user.name} - ${user.email}`,
|
||||||
|
},
|
||||||
|
[selectedUser, user, users]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
||||||
|
|
||||||
@@ -118,10 +124,7 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
loadOptions={loadOptions}
|
loadOptions={loadOptions}
|
||||||
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
||||||
options={users}
|
options={users}
|
||||||
defaultValue={{
|
defaultValue={selectedUserValue}
|
||||||
value: user.id,
|
|
||||||
label: `${user.name} - ${user.email}`,
|
|
||||||
}}
|
|
||||||
onChange={(value) => setStatsUserId(value?.value!)}
|
onChange={(value) => setStatsUserId(value?.value!)}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useRouter } from "next/router";
|
|||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import { getExamById } from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import { Exam, UserSolution } from "@/interfaces/exam";
|
|
||||||
import ModuleBadge from "../ModuleBadge";
|
import ModuleBadge from "../ModuleBadge";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import { findBy } from "@/utils";
|
import { findBy } from "@/utils";
|
||||||
|
|||||||
@@ -121,12 +121,12 @@ export default function Sidebar({
|
|||||||
entities,
|
entities,
|
||||||
"view_statistics"
|
"view_statistics"
|
||||||
);
|
);
|
||||||
|
|
||||||
const entitiesAllowPaymentRecord = useAllowedEntities(
|
const entitiesAllowPaymentRecord = useAllowedEntities(
|
||||||
user,
|
user,
|
||||||
entities,
|
entities,
|
||||||
"view_payment_record"
|
"view_payment_record"
|
||||||
);
|
);
|
||||||
|
|
||||||
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
|
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
|
||||||
user,
|
user,
|
||||||
entities,
|
entities,
|
||||||
@@ -148,7 +148,7 @@ export default function Sidebar({
|
|||||||
viewTickets: true,
|
viewTickets: true,
|
||||||
viewClassrooms: true,
|
viewClassrooms: true,
|
||||||
viewSettings: true,
|
viewSettings: true,
|
||||||
viewPaymentRecord: true,
|
viewPaymentRecords: true,
|
||||||
viewGeneration: true,
|
viewGeneration: true,
|
||||||
viewApprovalWorkflows: true,
|
viewApprovalWorkflows: true,
|
||||||
};
|
};
|
||||||
@@ -160,7 +160,7 @@ export default function Sidebar({
|
|||||||
viewTickets: false,
|
viewTickets: false,
|
||||||
viewClassrooms: false,
|
viewClassrooms: false,
|
||||||
viewSettings: false,
|
viewSettings: false,
|
||||||
viewPaymentRecord: false,
|
viewPaymentRecords: false,
|
||||||
viewGeneration: false,
|
viewGeneration: false,
|
||||||
viewApprovalWorkflows: false,
|
viewApprovalWorkflows: false,
|
||||||
};
|
};
|
||||||
@@ -235,7 +235,7 @@ export default function Sidebar({
|
|||||||
) &&
|
) &&
|
||||||
entitiesAllowPaymentRecord.length > 0
|
entitiesAllowPaymentRecord.length > 0
|
||||||
) {
|
) {
|
||||||
sidebarPermissions["viewPaymentRecord"] = true;
|
sidebarPermissions["viewPaymentRecords"] = true;
|
||||||
}
|
}
|
||||||
return sidebarPermissions;
|
return sidebarPermissions;
|
||||||
}, [
|
}, [
|
||||||
@@ -378,7 +378,6 @@ export default function Sidebar({
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav
|
<Nav
|
||||||
@@ -427,6 +426,16 @@ export default function Sidebar({
|
|||||||
isMinimized
|
isMinimized
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sidebarPermissions["viewPaymentRecords"] && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Payment Record"
|
||||||
|
path={path}
|
||||||
|
keyPath="/payment-record"
|
||||||
|
isMinimized
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{sidebarPermissions["viewSettings"] && (
|
{sidebarPermissions["viewSettings"] && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
@@ -459,7 +468,7 @@ export default function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
|
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8 ">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
@@ -483,7 +492,7 @@ export default function Sidebar({
|
|||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={focusMode ? () => {} : logout}
|
onClick={focusMode ? () => {} : logout}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out -xl:px-4",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ export default function usePagination<T>(list: T[], size = 25) {
|
|||||||
<Select
|
<Select
|
||||||
value={{
|
value={{
|
||||||
value: itemsPerPage.toString(),
|
value: itemsPerPage.toString(),
|
||||||
label: (itemsPerPage * page > items.length
|
label: itemsPerPage.toString(),
|
||||||
? items.length
|
|
||||||
: itemsPerPage * page
|
|
||||||
).toString(),
|
|
||||||
}}
|
}}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setItemsPerPage(parseInt(value!.value ?? "25"))
|
setItemsPerPage(parseInt(value!.value ?? "25"))
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import { Type, User } from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -15,444 +13,587 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import CodeGenImportSummary, { ExcelCodegenDuplicatesMap } from "@/components/ImportSummaries/Codegen";
|
import CodeGenImportSummary, {
|
||||||
|
ExcelCodegenDuplicatesMap,
|
||||||
|
} from "@/components/ImportSummaries/Codegen";
|
||||||
import { FaFileDownload } from "react-icons/fa";
|
import { FaFileDownload } from "react-icons/fa";
|
||||||
import { IoInformationCircleOutline } from "react-icons/io5";
|
import { IoInformationCircleOutline } from "react-icons/io5";
|
||||||
import { HiOutlineDocumentText } from "react-icons/hi";
|
import { HiOutlineDocumentText } from "react-icons/hi";
|
||||||
import CodegenTable from "@/components/Tables/CodeGenTable";
|
import CodegenTable from "@/components/Tables/CodeGenTable";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "createCodeTeacher",
|
perm: "createCodeTeacher",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "createCodeCountryManager",
|
perm: "createCodeCountryManager",
|
||||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
list: ["student", "teacher"],
|
list: ["student", "teacher"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "corporate"],
|
list: ["student", "teacher", "corporate"],
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
},
|
"student",
|
||||||
developer: {
|
"teacher",
|
||||||
perm: undefined,
|
"agent",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
"corporate",
|
||||||
},
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) {
|
export default function BatchCodeGenerator({
|
||||||
const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]);
|
user,
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
users,
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
entities = [],
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
permissions,
|
||||||
);
|
onFinish,
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
}: Props) {
|
||||||
const [type, setType] = useState<Type>("student");
|
const [infos, setInfos] = useState<
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
{ email: string; name: string; passport_id: string }[]
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
>([]);
|
||||||
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined });
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>();
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
|
const [parsedExcel, setParsedExcel] = useState<{
|
||||||
|
rows?: any[];
|
||||||
|
errors?: any[];
|
||||||
|
}>({ rows: undefined, errors: undefined });
|
||||||
|
const [duplicatedRows, setDuplicatedRows] = useState<{
|
||||||
|
duplicates: ExcelCodegenDuplicatesMap;
|
||||||
|
count: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
'First Name': {
|
"First Name": {
|
||||||
prop: 'firstName',
|
prop: "firstName",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('First Name cannot be empty')
|
throw new Error("First Name cannot be empty");
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
'Last Name': {
|
"Last Name": {
|
||||||
prop: 'lastName',
|
prop: "lastName",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Last Name cannot be empty')
|
throw new Error("Last Name cannot be empty");
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
'Passport/National ID': {
|
"Passport/National ID": {
|
||||||
prop: 'passport_id',
|
prop: "passport_id",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Passport/National ID cannot be empty')
|
throw new Error("Passport/National ID cannot be empty");
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
'E-mail': {
|
"E-mail": {
|
||||||
prop: 'email',
|
prop: "email",
|
||||||
required: true,
|
required: true,
|
||||||
type: (value: any) => {
|
type: (value: any) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Email cannot be empty')
|
throw new Error("Email cannot be empty");
|
||||||
}
|
}
|
||||||
if (!EMAIL_REGEX.test(value.trim())) {
|
if (!EMAIL_REGEX.test(value.trim())) {
|
||||||
throw new Error('Invalid Email')
|
throw new Error("Invalid Email");
|
||||||
}
|
}
|
||||||
return value
|
return value;
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
readXlsxFile(
|
readXlsxFile(file.content, { schema, ignoreEmptyRows: false }).then(
|
||||||
file.content, { schema, ignoreEmptyRows: false })
|
(data) => {
|
||||||
.then((data) => {
|
setParsedExcel(data);
|
||||||
setParsedExcel(data)
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parsedExcel.rows) {
|
if (parsedExcel.rows) {
|
||||||
const duplicates: ExcelCodegenDuplicatesMap = {
|
const duplicates: ExcelCodegenDuplicatesMap = {
|
||||||
email: new Map(),
|
email: new Map(),
|
||||||
passport_id: new Map(),
|
passport_id: new Map(),
|
||||||
};
|
};
|
||||||
const duplicateValues = new Set<string>();
|
const duplicateValues = new Set<string>();
|
||||||
const duplicateRowIndices = new Set<number>();
|
const duplicateRowIndices = new Set<number>();
|
||||||
|
|
||||||
const errorRowIndices = new Set(
|
const errorRowIndices = new Set(
|
||||||
parsedExcel.errors?.map(error => error.row) || []
|
parsedExcel.errors?.map((error) => error.row) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
parsedExcel.rows.forEach((row, index) => {
|
parsedExcel.rows.forEach((row, index) => {
|
||||||
if (!errorRowIndices.has(index + 2)) {
|
if (!errorRowIndices.has(index + 2)) {
|
||||||
(Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).forEach(field => {
|
(
|
||||||
if (row !== null) {
|
Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
|
||||||
const value = row[field];
|
).forEach((field) => {
|
||||||
if (value) {
|
if (row !== null) {
|
||||||
if (!duplicates[field].has(value)) {
|
const value = row[field];
|
||||||
duplicates[field].set(value, [index + 2]);
|
if (value) {
|
||||||
} else {
|
if (!duplicates[field].has(value)) {
|
||||||
const existingRows = duplicates[field].get(value);
|
duplicates[field].set(value, [index + 2]);
|
||||||
if (existingRows) {
|
} else {
|
||||||
existingRows.push(index + 2);
|
const existingRows = duplicates[field].get(value);
|
||||||
duplicateValues.add(value);
|
if (existingRows) {
|
||||||
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
|
existingRows.push(index + 2);
|
||||||
}
|
duplicateValues.add(value);
|
||||||
}
|
existingRows.forEach((rowNum) =>
|
||||||
}
|
duplicateRowIndices.add(rowNum)
|
||||||
}
|
);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const info = parsedExcel.rows
|
const info = parsedExcel.rows
|
||||||
.map((row, index) => {
|
.map((row, index) => {
|
||||||
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
|
if (
|
||||||
return undefined;
|
errorRowIndices.has(index + 2) ||
|
||||||
}
|
duplicateRowIndices.has(index + 2) ||
|
||||||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
|
row === null
|
||||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
studentID,
|
||||||
|
passport_id,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
group,
|
||||||
|
country,
|
||||||
|
} = row;
|
||||||
|
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: email.toString().trim().toLowerCase(),
|
email: email.toString().trim().toLowerCase(),
|
||||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
};
|
};
|
||||||
}).filter((x) => !!x) as typeof infos;
|
})
|
||||||
|
.filter((x) => !!x) as typeof infos;
|
||||||
|
|
||||||
setInfos(info);
|
setInfos(info);
|
||||||
}
|
}
|
||||||
}, [entity, parsedExcel, type]);
|
}, [entity, parsedExcel, type]);
|
||||||
|
|
||||||
const generateAndInvite = async () => {
|
const generateAndInvite = async () => {
|
||||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
const newUsers = infos.filter(
|
||||||
const existingUsers = infos
|
(x) => !users.map((u) => u.email).includes(x.email)
|
||||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
);
|
||||||
.map((i) => users.find((u) => u.email === i.email))
|
const existingUsers = infos
|
||||||
.filter((x) => !!x && x.type === "student") as User[];
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
|
.map((i) => users.find((u) => u.email === i.email))
|
||||||
|
.filter((x) => !!x && x.type === "student") as User[];
|
||||||
|
|
||||||
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
const newUsersSentence =
|
||||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||||
if (
|
const existingUsersSentence =
|
||||||
!confirm(
|
existingUsers.length > 0
|
||||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
? `invite ${existingUsers.length} registered student(s)`
|
||||||
)
|
: undefined;
|
||||||
)
|
if (
|
||||||
return;
|
!confirm(
|
||||||
|
`You are about to ${[newUsersSentence, existingUsersSentence]
|
||||||
|
.filter((x) => !!x)
|
||||||
|
.join(" and ")}, are you sure you want to continue?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id })))
|
Promise.all(
|
||||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
existingUsers.map(
|
||||||
.finally(() => {
|
async (u) =>
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||||
});
|
)
|
||||||
|
)
|
||||||
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||||
setInfos([]);
|
setInfos([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateCode = (type: Type, informations: typeof infos) => {
|
const generateCode = (type: Type, informations: typeof infos) => {
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const codes = informations.map(() => uid.randomUUID(6));
|
const codes = informations.map(() => uid.randomUUID(6));
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||||
type,
|
type,
|
||||||
codes,
|
codes,
|
||||||
infos: informations.map((info, index) => ({ ...info, code: codes[index] })),
|
infos: informations.map((info, index) => ({
|
||||||
expiryDate,
|
...info,
|
||||||
entity
|
code: codes[index],
|
||||||
})
|
})),
|
||||||
.then(({ data, status }) => {
|
expiryDate,
|
||||||
if (data.ok) {
|
entity,
|
||||||
toast.success(
|
})
|
||||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
.then(({ data, status }) => {
|
||||||
type,
|
if (data.ok) {
|
||||||
)} codes and they have been notified by e-mail!`,
|
toast.success(
|
||||||
{ toastId: "success" },
|
`Successfully generated${
|
||||||
);
|
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
||||||
|
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
||||||
|
{ toastId: "success" }
|
||||||
|
);
|
||||||
|
|
||||||
onFinish();
|
onFinish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({ response: { status, data } }) => {
|
.catch(({ response: { status, data } }) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {
|
toast.error(`Something went wrong, please try again later!`, {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return clear();
|
return clear();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTemplateDownload = () => {
|
const handleTemplateDownload = () => {
|
||||||
const fileName = "BatchCodeTemplate.xlsx";
|
const fileName = "BatchCodeTemplate.xlsx";
|
||||||
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
||||||
<>
|
<>
|
||||||
<div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div>
|
<div className="flex font-bold text-xl justify-center text-gray-700">
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<span>Excel File Format</span>
|
||||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} />
|
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||||
<h2 className="text-lg font-semibold">
|
<div className="flex items-center gap-2">
|
||||||
The uploaded document must:
|
<HiOutlineDocumentText
|
||||||
</h2>
|
className={`w-5 h-5 text-mti-purple-light`}
|
||||||
</div>
|
/>
|
||||||
<ul className="flex flex-col pl-10 gap-2">
|
<h2 className="text-lg font-semibold">
|
||||||
<li className="text-gray-700 list-disc">
|
The uploaded document must:
|
||||||
be an Excel .xlsx document.
|
</h2>
|
||||||
</li>
|
</div>
|
||||||
<li className="text-gray-700 list-disc">
|
<ul className="flex flex-col pl-10 gap-2">
|
||||||
only have a single spreadsheet with the following <b>exact same name</b> columns:
|
<li className="text-gray-700 list-disc">
|
||||||
<div className="py-4 pr-4">
|
be an Excel .xlsx document.
|
||||||
<table className="w-full bg-white">
|
</li>
|
||||||
<thead>
|
<li className="text-gray-700 list-disc">
|
||||||
<tr>
|
only have a single spreadsheet with the following{" "}
|
||||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
<b>exact same name</b> columns:
|
||||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
<div className="py-4 pr-4">
|
||||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
<table className="w-full bg-white">
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<thead>
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
</table>
|
First Name
|
||||||
</div>
|
</th>
|
||||||
</li>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
</ul>
|
Last Name
|
||||||
</div>
|
</th>
|
||||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
<div className="flex items-center gap-2">
|
Passport/National ID
|
||||||
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} />
|
</th>
|
||||||
<h2 className="text-lg font-semibold">
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
Note that:
|
E-mail
|
||||||
</h2>
|
</th>
|
||||||
</div>
|
</tr>
|
||||||
<ul className="flex flex-col pl-10 gap-2">
|
</thead>
|
||||||
<li className="text-gray-700 list-disc">
|
</table>
|
||||||
all incorrect e-mails will be ignored.
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-gray-700 list-disc">
|
</ul>
|
||||||
all already registered e-mails will be ignored.
|
</div>
|
||||||
</li>
|
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||||
<li className="text-gray-700 list-disc">
|
<div className="flex items-center gap-2">
|
||||||
all rows which contain duplicate values in the columns: "Passport/National ID", "E-mail", will be ignored.
|
<IoInformationCircleOutline
|
||||||
</li>
|
className={`w-5 h-5 text-mti-purple-light`}
|
||||||
<li className="text-gray-700 list-disc">
|
/>
|
||||||
all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
|
<h2 className="text-lg font-semibold">Note that:</h2>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
<ul className="flex flex-col pl-10 gap-2">
|
||||||
</div>
|
<li className="text-gray-700 list-disc">
|
||||||
<div className="bg-gray-100 rounded-lg p-4">
|
all incorrect e-mails will be ignored.
|
||||||
<p className="text-gray-600">
|
</li>
|
||||||
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
|
<li className="text-gray-700 list-disc">
|
||||||
</p>
|
all already registered e-mails will be ignored.
|
||||||
</div>
|
</li>
|
||||||
<div className="w-full flex justify-between mt-6 gap-8">
|
<li className="text-gray-700 list-disc">
|
||||||
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white">
|
all rows which contain duplicate values in the columns:
|
||||||
Close
|
"Passport/National ID", "E-mail", will be
|
||||||
</Button>
|
ignored.
|
||||||
|
</li>
|
||||||
|
<li className="text-gray-700 list-disc">
|
||||||
|
all of the e-mails in the file will receive an e-mail to join
|
||||||
|
EnCoach with the role selected below.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-100 rounded-lg p-4">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-between mt-6 gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => setShowHelp(false)}
|
||||||
|
variant="outline"
|
||||||
|
className="self-end w-full bg-white"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
|
<Button
|
||||||
<div className="flex items-center gap-2">
|
color="purple"
|
||||||
<FaFileDownload size={24} />
|
onClick={handleTemplateDownload}
|
||||||
Download Template
|
variant="solid"
|
||||||
</div>
|
className="self-end w-full"
|
||||||
</Button>
|
>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<FaFileDownload size={24} />
|
||||||
</>
|
Download Template
|
||||||
</Modal>
|
</div>
|
||||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
</Button>
|
||||||
<div className="flex items-end justify-between">
|
</div>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
</div>
|
||||||
<button
|
</>
|
||||||
onClick={() => setShowHelp(true)}
|
</Modal>
|
||||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
data-tip="Excel File Format"
|
<div className="flex items-end justify-between">
|
||||||
>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<IoInformationCircleOutline size={24} />
|
Choose an Excel file
|
||||||
</button>
|
</label>
|
||||||
</div>
|
<button
|
||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
onClick={() => setShowHelp(true)}
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||||
</Button>
|
data-tip="Excel File Format"
|
||||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
>
|
||||||
<>
|
<IoInformationCircleOutline size={24} />
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
</button>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
</div>
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
<Button
|
||||||
Enabled
|
onClick={openFilePicker}
|
||||||
</Checkbox>
|
isLoading={isLoading}
|
||||||
</div>
|
disabled={isLoading}
|
||||||
{isExpiryDateEnabled && (
|
>
|
||||||
<ReactDatePicker
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
className={clsx(
|
</Button>
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
{user &&
|
||||||
"hover:border-mti-purple tooltip",
|
checkAccess(user, [
|
||||||
"transition duration-300 ease-in-out",
|
"developer",
|
||||||
)}
|
"admin",
|
||||||
filterDate={(date) =>
|
"corporate",
|
||||||
moment(date).isAfter(new Date()) &&
|
"mastercorporate",
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
]) && (
|
||||||
}
|
<>
|
||||||
dateFormat="dd/MM/yyyy"
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
selected={expiryDate}
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
onChange={(date) => setExpiryDate(date)}
|
Expiry Date
|
||||||
/>
|
</label>
|
||||||
)}
|
<Checkbox
|
||||||
</>
|
isChecked={isExpiryDateEnabled}
|
||||||
)}
|
onChange={setIsExpiryDateEnabled}
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
>
|
||||||
<Select
|
Enabled
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
</Checkbox>
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
</div>
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
{isExpiryDateEnabled && (
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
<ReactDatePicker
|
||||||
/>
|
className={clsx(
|
||||||
</div>
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
"hover:border-mti-purple tooltip",
|
||||||
{user && (
|
"transition duration-300 ease-in-out"
|
||||||
<select
|
)}
|
||||||
defaultValue="student"
|
filterDate={(date) =>
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
moment(date).isAfter(new Date()) &&
|
||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
(user.subscriptionExpirationDate
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
.filter((x) => {
|
: true)
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
}
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
dateFormat="dd/MM/yyyy"
|
||||||
})
|
selected={expiryDate}
|
||||||
.map((type) => (
|
onChange={(date) => setExpiryDate(date)}
|
||||||
<option key={type} value={type}>
|
/>
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
)}
|
||||||
</option>
|
</>
|
||||||
))}
|
)}
|
||||||
</select>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
)}
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>}
|
Entity
|
||||||
{infos.length !== 0 && (
|
</label>
|
||||||
<div className="flex w-full flex-col gap-4">
|
<Select
|
||||||
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span>
|
defaultValue={{
|
||||||
<CodegenTable infos={infos} />
|
value: (entities || [])[0]?.id,
|
||||||
</div>
|
label: (entities || [])[0]?.label,
|
||||||
)}
|
}}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
Generate & Send
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
</Button>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
</>
|
Select the type of user they should be
|
||||||
);
|
</label>
|
||||||
|
{user && (
|
||||||
|
<select
|
||||||
|
defaultValue="student"
|
||||||
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||||
|
>
|
||||||
|
{Object.keys(USER_TYPE_LABELS).reduce((acc, x) => {
|
||||||
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
|
acc.push(
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
}, [] as JSX.Element[])}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{infos.length > 0 && (
|
||||||
|
<CodeGenImportSummary
|
||||||
|
infos={infos}
|
||||||
|
parsedExcel={parsedExcel}
|
||||||
|
duplicateRows={duplicatedRows}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{infos.length !== 0 && (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<span className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Codes will be sent to:
|
||||||
|
</span>
|
||||||
|
<CodegenTable infos={infos} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{checkAccess(
|
||||||
|
user,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"createCodes"
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
onClick={generateAndInvite}
|
||||||
|
disabled={
|
||||||
|
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Generate & Send
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
import { Type, User } from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -13,173 +12,225 @@ import { toast } from "react-toastify";
|
|||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "createCodeTeacher",
|
perm: "createCodeTeacher",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "createCodeCountryManager",
|
perm: "createCodeCountryManager",
|
||||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
list: ["student", "teacher"],
|
list: ["student", "teacher"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "corporate"],
|
list: ["student", "teacher", "corporate"],
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
},
|
"student",
|
||||||
developer: {
|
"teacher",
|
||||||
perm: undefined,
|
"agent",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
"corporate",
|
||||||
},
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) {
|
export default function CodeGenerator({
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
user,
|
||||||
|
entities = [],
|
||||||
|
permissions,
|
||||||
|
onFinish,
|
||||||
|
}: Props) {
|
||||||
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
|
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
);
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
: null
|
||||||
const [type, setType] = useState<Type>("student");
|
);
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const generateCode = (type: Type) => {
|
const generateCode = (type: Type) => {
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const code = uid.randomUUID(6);
|
const code = uid.randomUUID(6);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post("/api/code", { type, codes: [code], expiryDate, entity })
|
.post("/api/code", { type, codes: [code], expiryDate, entity })
|
||||||
.then(({ data, status }) => {
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||||
toastId: "success",
|
toastId: "success",
|
||||||
});
|
});
|
||||||
setGeneratedCode(code);
|
setGeneratedCode(code);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({ response: { status, data } }) => {
|
.catch(({ response: { status, data } }) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {
|
toast.error(`Something went wrong, please try again later!`, {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
User Code Generator
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
</label>
|
||||||
<Select
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
Entity
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
</label>
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
<Select
|
||||||
/>
|
defaultValue={{
|
||||||
</div>
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
>
|
||||||
.filter((x) => {
|
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
})
|
acc.push(x);
|
||||||
.map((type) => (
|
return acc;
|
||||||
<option key={type} value={type}>
|
}, [])}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
</select>
|
||||||
</option>
|
</div>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{checkAccess(user, [
|
||||||
<>
|
"developer",
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
"admin",
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
"corporate",
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
"mastercorporate",
|
||||||
Enabled
|
]) && (
|
||||||
</Checkbox>
|
<>
|
||||||
</div>
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
{isExpiryDateEnabled && (
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<ReactDatePicker
|
Expiry Date
|
||||||
className={clsx(
|
</label>
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
<Checkbox
|
||||||
"hover:border-mti-purple tooltip",
|
isChecked={isExpiryDateEnabled}
|
||||||
"transition duration-300 ease-in-out",
|
onChange={setIsExpiryDateEnabled}
|
||||||
)}
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
filterDate={(date) =>
|
>
|
||||||
moment(date).isAfter(new Date()) &&
|
Enabled
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
</Checkbox>
|
||||||
}
|
</div>
|
||||||
dateFormat="dd/MM/yyyy"
|
{isExpiryDateEnabled && (
|
||||||
selected={expiryDate}
|
<ReactDatePicker
|
||||||
onChange={(date) => setExpiryDate(date)}
|
className={clsx(
|
||||||
/>
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
)}
|
"hover:border-mti-purple tooltip",
|
||||||
</>
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
filterDate={(date) =>
|
||||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
moment(date).isAfter(new Date()) &&
|
||||||
Generate
|
(user.subscriptionExpirationDate
|
||||||
</Button>
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
)}
|
: true)
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
}
|
||||||
<div
|
dateFormat="dd/MM/yyyy"
|
||||||
className={clsx(
|
selected={expiryDate}
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
onChange={(date) => setExpiryDate(date)}
|
||||||
"hover:border-mti-purple tooltip",
|
/>
|
||||||
"transition duration-300 ease-in-out",
|
)}
|
||||||
)}
|
</>
|
||||||
data-tip="Click to copy"
|
)}
|
||||||
onClick={() => {
|
{checkAccess(
|
||||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
user,
|
||||||
}}>
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
{generatedCode}
|
permissions,
|
||||||
</div>
|
"createCodes"
|
||||||
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
) && (
|
||||||
</div>
|
<Button
|
||||||
);
|
onClick={() => generateCode(type)}
|
||||||
|
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Generated Code:
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
"transition duration-300 ease-in-out"
|
||||||
|
)}
|
||||||
|
data-tip="Click to copy"
|
||||||
|
onClick={() => {
|
||||||
|
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generatedCode}
|
||||||
|
</div>
|
||||||
|
{generatedCode && (
|
||||||
|
<span className="text-sm text-mti-gray-dim font-light">
|
||||||
|
Give this code to the user to complete their registration
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize, uniqBy } from "lodash";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs";
|
import { BsPencil, BsTrash, BsUpload } from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import { Group, User } from "@/interfaces/user";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
import { CorporateUser, Group, User } from "@/interfaces/user";
|
|
||||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize, uniq } from "lodash";
|
import { uniq } from "lodash";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import { getUserCorporate } from "@/utils/groups";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user";
|
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
|
||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||||
import { WithEntity } from "@/interfaces/entity";
|
import { WithEntity } from "@/interfaces/entity";
|
||||||
|
|
||||||
const searchFields = [["name"]];
|
const searchFields = [["name"]];
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
interface CreateDialogProps {
|
interface CreateDialogProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -35,9 +34,13 @@ interface CreateDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
const [name, setName] = useState<string | undefined>(
|
||||||
|
group?.name || undefined
|
||||||
|
);
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
const [participants, setParticipants] = useState<string[]>(
|
||||||
|
group?.participants || []
|
||||||
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
@@ -47,9 +50,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const availableUsers = useMemo(() => {
|
const availableUsers = useMemo(() => {
|
||||||
if (user?.type === "teacher") return users.filter((x) => ["student"].includes(x.type));
|
if (user?.type === "teacher")
|
||||||
if (user?.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type));
|
return users.filter((x) => ["student"].includes(x.type));
|
||||||
if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type));
|
if (user?.type === "corporate")
|
||||||
|
return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||||
|
if (user?.type === "mastercorporate")
|
||||||
|
return users.filter((x) =>
|
||||||
|
["corporate", "teacher", "student"].includes(x.type)
|
||||||
|
);
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}, [user, users]);
|
}, [user, users]);
|
||||||
@@ -64,9 +72,12 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [email] = row as string[];
|
const [email] = row as string[];
|
||||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
return EMAIL_REGEX.test(email) &&
|
||||||
|
!users.map((u) => u.email).includes(email)
|
||||||
|
? email.toString().trim()
|
||||||
|
: undefined;
|
||||||
})
|
})
|
||||||
.filter((x) => !!x),
|
.filter((x) => !!x)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
@@ -76,12 +87,17 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
const emailUsers = [...new Set(emails)]
|
||||||
|
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||||
|
.filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
|
((user.type === "developer" ||
|
||||||
|
user.type === "admin" ||
|
||||||
|
user.type === "corporate" ||
|
||||||
|
user.type === "mastercorporate") &&
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student")
|
||||||
);
|
);
|
||||||
|
|
||||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||||
@@ -89,7 +105,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
user.type !== "teacher"
|
user.type !== "teacher"
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
: "Added all students found in the file you've provided!",
|
: "Added all students found in the file you've provided!",
|
||||||
{ toastId: "upload-success" },
|
{ toastId: "upload-success" }
|
||||||
);
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -100,15 +116,27 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
|
if (
|
||||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
name !== group?.name &&
|
||||||
|
(name?.trim() === "Students" ||
|
||||||
|
name?.trim() === "Teachers" ||
|
||||||
|
name?.trim() === "Corporate")
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"That group name is reserved and cannot be used, please enter another one."
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants })
|
(group ? axios.patch : axios.post)(
|
||||||
|
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||||
|
{ name, admin, participants }
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
toast.success(
|
||||||
|
`Group "${name}" ${group ? "edited" : "created"} successfully`
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -121,30 +149,58 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
availableUsers.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.email} - ${x.name}`,
|
||||||
|
})),
|
||||||
|
[availableUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() =>
|
||||||
|
participants.map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: `${users.find((y) => y.id === x)?.email} - ${
|
||||||
|
users.find((y) => y.id === x)?.name
|
||||||
|
}`,
|
||||||
|
})),
|
||||||
|
[participants, users]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
<Input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label="Name"
|
||||||
|
defaultValue={name}
|
||||||
|
onChange={setName}
|
||||||
|
required
|
||||||
|
disabled={group?.disableEditing}
|
||||||
|
/>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
Participants
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||||
|
>
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full gap-8">
|
<div className="flex w-full gap-8">
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={participants.map((x) => ({
|
value={value}
|
||||||
value: x,
|
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
placeholder="Participants..."
|
placeholder="Participants..."
|
||||||
defaultValue={participants.map((x) => ({
|
defaultValue={value}
|
||||||
value: x,
|
options={userOptions}
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
options={availableUsers.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
isMulti
|
isMulti
|
||||||
isSearchable
|
isSearchable
|
||||||
@@ -160,18 +216,36 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
<Button
|
||||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
className="w-full max-w-[300px] h-fit"
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{filesContent.length === 0
|
||||||
|
? "Upload participants Excel file"
|
||||||
|
: filesContent[0].name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={submit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!name}
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +256,8 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
export default function GroupList({ user }: { user: User }) {
|
export default function GroupList({ user }: { user: User }) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
const [viewingAllParticipants, setViewingAllParticipants] =
|
||||||
|
useState<string>();
|
||||||
|
|
||||||
const { permissions } = usePermissions(user?.id || "");
|
const { permissions } = usePermissions(user?.id || "");
|
||||||
|
|
||||||
@@ -211,7 +286,14 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
columnHelper.accessor("admin", {
|
columnHelper.accessor("admin", {
|
||||||
header: "Admin",
|
header: "Admin",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip={
|
||||||
|
USER_TYPE_LABELS[
|
||||||
|
users.find((x) => x.id === info.getValue())?.type || "student"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -226,23 +308,30 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
<span>
|
<span>
|
||||||
{info
|
{info
|
||||||
.getValue()
|
.getValue()
|
||||||
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
.slice(
|
||||||
|
0,
|
||||||
|
viewingAllParticipants === info.row.original.id ? undefined : 5
|
||||||
|
)
|
||||||
.map((x) => users.find((y) => y.id === x)?.name)
|
.map((x) => users.find((y) => y.id === x)?.name)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
|
{info.getValue().length > 5 &&
|
||||||
<button
|
viewingAllParticipants !== info.row.original.id && (
|
||||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
<button
|
||||||
onClick={() => setViewingAllParticipants(info.row.original.id)}>
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
, View More
|
onClick={() => setViewingAllParticipants(info.row.original.id)}
|
||||||
</button>
|
>
|
||||||
)}
|
, View More
|
||||||
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
|
</button>
|
||||||
<button
|
)}
|
||||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
{info.getValue().length > 5 &&
|
||||||
onClick={() => setViewingAllParticipants(undefined)}>
|
viewingAllParticipants === info.row.original.id && (
|
||||||
, View Less
|
<button
|
||||||
</button>
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
)}
|
onClick={() => setViewingAllParticipants(undefined)}
|
||||||
|
>
|
||||||
|
, View Less
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -252,20 +341,34 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
cell: ({ row }: { row: { original: Group } }) => {
|
cell: ({ row }: { row: { original: Group } }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
{user &&
|
||||||
<div className="flex gap-2">
|
(checkAccess(user, ["developer", "admin"]) ||
|
||||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
|
user.id === row.original.admin) && (
|
||||||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
<div className="flex gap-2">
|
||||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
{(!row.original.disableEditing ||
|
||||||
</div>
|
checkAccess(user, ["developer", "admin"]),
|
||||||
)}
|
"editGroup") && (
|
||||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
|
<div
|
||||||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
data-tip="Edit"
|
||||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
className="tooltip cursor-pointer"
|
||||||
</div>
|
onClick={() => setEditingGroup(row.original)}
|
||||||
)}
|
>
|
||||||
</div>
|
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
{(!row.original.disableEditing ||
|
||||||
|
checkAccess(user, ["developer", "admin"]),
|
||||||
|
"deleteGroup") && (
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
onClick={() => deleteGroup(row.original)}
|
||||||
|
>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -280,7 +383,11 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
||||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
<Modal
|
||||||
|
isOpen={isCreating || !!editingGroup}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||||
|
>
|
||||||
<CreatePanel
|
<CreatePanel
|
||||||
group={editingGroup}
|
group={editingGroup}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -288,12 +395,22 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
users={users}
|
users={users}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Table data={groups} columns={defaultColumns} searchFields={searchFields} />
|
<Table
|
||||||
|
data={groups}
|
||||||
|
columns={defaultColumns}
|
||||||
|
searchFields={searchFields}
|
||||||
|
/>
|
||||||
|
|
||||||
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
|
{checkAccess(
|
||||||
|
user,
|
||||||
|
["teacher", "corporate", "mastercorporate", "admin", "developer"],
|
||||||
|
permissions,
|
||||||
|
"createGroup"
|
||||||
|
) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||||
|
>
|
||||||
New Group
|
New Group
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,256 +1,341 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import usePackages from "@/hooks/usePackages";
|
import usePackages from "@/hooks/usePackages";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Package} from "@/interfaces/paypal";
|
import { Package } from "@/interfaces/paypal";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import {useState} from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {BsPencil, BsTrash} from "react-icons/bs";
|
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {CURRENCIES} from "@/resources/paypal";
|
import { CURRENCIES } from "@/resources/paypal";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
|
|
||||||
const CLASSES: {[key in Module]: string} = {
|
const CLASSES: { [key in Module]: string } = {
|
||||||
reading: "text-ielts-reading",
|
reading: "text-ielts-reading",
|
||||||
listening: "text-ielts-listening",
|
listening: "text-ielts-listening",
|
||||||
speaking: "text-ielts-speaking",
|
speaking: "text-ielts-speaking",
|
||||||
writing: "text-ielts-writing",
|
writing: "text-ielts-writing",
|
||||||
level: "text-ielts-level",
|
level: "text-ielts-level",
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Package>();
|
const columnHelper = createColumnHelper<Package>();
|
||||||
|
|
||||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||||
|
|
||||||
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
|
const currencyOptions = CURRENCIES.map(({ label, currency }) => ({
|
||||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
value: currency,
|
||||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
const [price, setPrice] = useState(pack?.price || 0);
|
function PackageCreator({
|
||||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
pack,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
pack?: Package;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||||
|
const [unit, setUnit] = useState<DurationUnit>(
|
||||||
|
pack?.duration_unit || "months"
|
||||||
|
);
|
||||||
|
|
||||||
const submit = () => {
|
const [price, setPrice] = useState(pack?.price || 0);
|
||||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||||
duration,
|
|
||||||
duration_unit: unit,
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("New payment has been created successfully!");
|
|
||||||
onClose();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Something went wrong, please try again later!");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const submit = useCallback(() => {
|
||||||
<div className="flex flex-col gap-8 py-8">
|
(pack ? axios.patch : axios.post)(
|
||||||
<div className="flex flex-col gap-3">
|
pack ? `/api/packages/${pack.id}` : "/api/packages",
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
|
{
|
||||||
<div className="flex gap-4 items-center">
|
duration,
|
||||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
duration_unit: unit,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("New payment has been created successfully!");
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
}, [duration, unit, price, currency, pack, onClose]);
|
||||||
|
|
||||||
<Select
|
const currencyDefaultValue = useMemo(() => {
|
||||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
return {
|
||||||
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
value: currency || "EUR",
|
||||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
|
||||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
};
|
||||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
}, [currency]);
|
||||||
menuPortalTarget={document?.body}
|
|
||||||
styles={{
|
return (
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
<div className="flex flex-col gap-8 py-8">
|
||||||
control: (styles) => ({
|
<div className="flex flex-col gap-3">
|
||||||
...styles,
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
paddingLeft: "4px",
|
Price *
|
||||||
border: "none",
|
</label>
|
||||||
outline: "none",
|
<div className="flex gap-4 items-center">
|
||||||
":focus": {
|
<Input
|
||||||
outline: "none",
|
defaultValue={price}
|
||||||
},
|
name="price"
|
||||||
}),
|
type="number"
|
||||||
option: (styles, state) => ({
|
onChange={(e) => setPrice(parseInt(e))}
|
||||||
...styles,
|
/>
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
<Select
|
||||||
}),
|
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
}}
|
options={currencyOptions}
|
||||||
/>
|
defaultValue={currencyDefaultValue}
|
||||||
</div>
|
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||||
</div>
|
value={currencyDefaultValue}
|
||||||
<div className="flex flex-col gap-3">
|
menuPortalTarget={document?.body}
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
|
styles={{
|
||||||
<div className="flex gap-4 items-center">
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
|
control: (styles) => ({
|
||||||
<Select
|
...styles,
|
||||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
paddingLeft: "4px",
|
||||||
options={[
|
border: "none",
|
||||||
{value: "days", label: "Days"},
|
outline: "none",
|
||||||
{value: "weeks", label: "Weeks"},
|
":focus": {
|
||||||
{value: "months", label: "Months"},
|
outline: "none",
|
||||||
{value: "years", label: "Years"},
|
},
|
||||||
]}
|
}),
|
||||||
defaultValue={{value: "months", label: "Months"}}
|
option: (styles, state) => ({
|
||||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
...styles,
|
||||||
value={{value: unit, label: capitalize(unit)}}
|
backgroundColor: state.isFocused
|
||||||
menuPortalTarget={document?.body}
|
? "#D5D9F0"
|
||||||
styles={{
|
: state.isSelected
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
? "#7872BF"
|
||||||
control: (styles) => ({
|
: "white",
|
||||||
...styles,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
paddingLeft: "4px",
|
}),
|
||||||
border: "none",
|
}}
|
||||||
outline: "none",
|
/>
|
||||||
":focus": {
|
</div>
|
||||||
outline: "none",
|
</div>
|
||||||
},
|
<div className="flex flex-col gap-3">
|
||||||
}),
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
option: (styles, state) => ({
|
Duration *
|
||||||
...styles,
|
</label>
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
<div className="flex gap-4 items-center">
|
||||||
color: state.isFocused ? "black" : styles.color,
|
<Input
|
||||||
}),
|
defaultValue={duration}
|
||||||
}}
|
name="duration"
|
||||||
/>
|
type="number"
|
||||||
</div>
|
onChange={(e) => setDuration(parseInt(e))}
|
||||||
</div>
|
/>
|
||||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
<Select
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
Cancel
|
options={[
|
||||||
</Button>
|
{ value: "days", label: "Days" },
|
||||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
{ value: "weeks", label: "Weeks" },
|
||||||
Submit
|
{ value: "months", label: "Months" },
|
||||||
</Button>
|
{ value: "years", label: "Years" },
|
||||||
</div>
|
]}
|
||||||
</div>
|
defaultValue={{ value: "months", label: "Months" }}
|
||||||
);
|
onChange={(value) =>
|
||||||
|
setUnit((value?.value as DurationUnit) || "months")
|
||||||
|
}
|
||||||
|
value={{ value: unit, label: capitalize(unit) }}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
control: (styles) => ({
|
||||||
|
...styles,
|
||||||
|
paddingLeft: "4px",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
":focus": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused
|
||||||
|
? "#D5D9F0"
|
||||||
|
: state.isSelected
|
||||||
|
? "#7872BF"
|
||||||
|
: "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!duration || !price}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageList({user}: {user: User}) {
|
export default function PackageList({ user }: { user: User }) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||||
|
|
||||||
const {packages, reload} = usePackages();
|
const { packages, reload } = usePackages();
|
||||||
|
|
||||||
const deletePackage = async (pack: Package) => {
|
const deletePackage = useCallback(
|
||||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
async (pack: Package) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/packages/${pack.id}`)
|
.delete(`/api/packages/${pack.id}`)
|
||||||
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
if (reason.response.status === 404) {
|
if (reason.response.status === 404) {
|
||||||
toast.error("Package not found!");
|
toast.error("Package not found!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
if (reason.response.status === 403) {
|
||||||
toast.error("You do not have permission to delete this exam!");
|
toast.error("You do not have permission to delete this exam!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error("Something went wrong, please try again later.");
|
toast.error("Something went wrong, please try again later.");
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
},
|
||||||
|
[reload]
|
||||||
|
);
|
||||||
|
|
||||||
const defaultColumns = [
|
const defaultColumns = useMemo(
|
||||||
columnHelper.accessor("id", {
|
() => [
|
||||||
header: "ID",
|
columnHelper.accessor("id", {
|
||||||
cell: (info) => info.getValue(),
|
header: "ID",
|
||||||
}),
|
cell: (info) => info.getValue(),
|
||||||
columnHelper.accessor("duration", {
|
}),
|
||||||
header: "Duration",
|
columnHelper.accessor("duration", {
|
||||||
cell: (info) => (
|
header: "Duration",
|
||||||
<span>
|
cell: (info) => (
|
||||||
{info.getValue()} {info.row.original.duration_unit}
|
<span>
|
||||||
</span>
|
{info.getValue()} {info.row.original.duration_unit}
|
||||||
),
|
</span>
|
||||||
}),
|
),
|
||||||
columnHelper.accessor("price", {
|
}),
|
||||||
header: "Price",
|
columnHelper.accessor("price", {
|
||||||
cell: (info) => (
|
header: "Price",
|
||||||
<span>
|
cell: (info) => (
|
||||||
{info.getValue()} {info.row.original.currency}
|
<span>
|
||||||
</span>
|
{info.getValue()} {info.row.original.currency}
|
||||||
),
|
</span>
|
||||||
}),
|
),
|
||||||
{
|
}),
|
||||||
header: "",
|
{
|
||||||
id: "actions",
|
header: "",
|
||||||
cell: ({row}: {row: {original: Package}}) => {
|
id: "actions",
|
||||||
return (
|
cell: ({ row }: { row: { original: Package } }) => {
|
||||||
<div className="flex gap-4">
|
return (
|
||||||
{["developer", "admin"].includes(user.type) && (
|
<div className="flex gap-4">
|
||||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
|
{["developer", "admin"].includes(user?.type) && (
|
||||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<div
|
||||||
</div>
|
data-tip="Edit"
|
||||||
)}
|
className="cursor-pointer tooltip"
|
||||||
{["developer", "admin"].includes(user.type) && (
|
onClick={() => setEditingPackage(row.original)}
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
|
>
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{["developer", "admin"].includes(user?.type) && (
|
||||||
);
|
<div
|
||||||
},
|
data-tip="Delete"
|
||||||
},
|
className="cursor-pointer tooltip"
|
||||||
];
|
onClick={() => deletePackage(row.original)}
|
||||||
|
>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[deletePackage, user]
|
||||||
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: packages,
|
data: packages,
|
||||||
columns: defaultColumns,
|
columns: defaultColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = useCallback(() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setEditingPackage(undefined);
|
setEditingPackage(undefined);
|
||||||
reload();
|
reload();
|
||||||
};
|
}, [reload]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full rounded-xl">
|
<div className="w-full h-full rounded-xl">
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isCreating || !!editingPackage}
|
isOpen={isCreating || !!editingPackage}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
|
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
|
||||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
>
|
||||||
</Modal>
|
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
</Modal>
|
||||||
<thead>
|
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<thead>
|
||||||
<tr key={headerGroup.id}>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
{headerGroup.headers.map((header) => (
|
<tr key={headerGroup.id}>
|
||||||
<th className="p-4 text-left" key={header.id}>
|
{headerGroup.headers.map((header) => (
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
<th className="p-4 text-left" key={header.id}>
|
||||||
</th>
|
{header.isPlaceholder
|
||||||
))}
|
? null
|
||||||
</tr>
|
: flexRender(
|
||||||
))}
|
header.column.columnDef.header,
|
||||||
</thead>
|
header.getContext()
|
||||||
<tbody className="px-2">
|
)}
|
||||||
{table.getRowModel().rows.map((row) => (
|
</th>
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
))}
|
||||||
{row.getVisibleCells().map((cell) => (
|
</tr>
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
))}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
</thead>
|
||||||
</td>
|
<tbody className="px-2">
|
||||||
))}
|
{table.getRowModel().rows.map((row) => (
|
||||||
</tr>
|
<tr
|
||||||
))}
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
</tbody>
|
key={row.id}
|
||||||
</table>
|
>
|
||||||
<button
|
{row.getVisibleCells().map((cell) => (
|
||||||
onClick={() => setIsCreating(true)}
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
New Package
|
</td>
|
||||||
</button>
|
))}
|
||||||
</div>
|
</tr>
|
||||||
);
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
|
||||||
|
>
|
||||||
|
New Package
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {averageLevelCalculator} from "@/utils/score";
|
|||||||
import {groupByExam} from "@/utils/stats";
|
import {groupByExam} from "@/utils/stats";
|
||||||
import {createColumnHelper} from "@tanstack/react-table";
|
import {createColumnHelper} from "@tanstack/react-table";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import List from "@/components/List";
|
|
||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
|
|
||||||
type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string};
|
type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BsCheck,
|
BsCheck,
|
||||||
BsCheckCircle,
|
BsCheckCircle,
|
||||||
|
|||||||
@@ -1,272 +1,375 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import CountrySelect from "@/components/Low/CountrySelect";
|
import CountrySelect from "@/components/Low/CountrySelect";
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {getUserName} from "@/utils/users";
|
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {EntityWithRoles} from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||||
import {mapBy} from "@/utils";
|
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "createCodeTeacher",
|
perm: "createCodeTeacher",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "createCodeCountryManager",
|
perm: "createCodeCountryManager",
|
||||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
list: ["student", "teacher"],
|
list: ["student", "teacher"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "corporate"],
|
list: ["student", "teacher", "corporate"],
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
},
|
"student",
|
||||||
developer: {
|
"teacher",
|
||||||
perm: undefined,
|
"agent",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
"corporate",
|
||||||
},
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
entities: EntityWithRoles[];
|
entities: EntityWithRoles[];
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserCreator({user, users, entities = [], permissions, onFinish}: Props) {
|
export default function UserCreator({
|
||||||
const [name, setName] = useState<string>();
|
user,
|
||||||
const [email, setEmail] = useState<string>();
|
users,
|
||||||
const [phone, setPhone] = useState<string>();
|
entities = [],
|
||||||
const [passportID, setPassportID] = useState<string>();
|
permissions,
|
||||||
const [studentID, setStudentID] = useState<string>();
|
onFinish,
|
||||||
const [country, setCountry] = useState(user?.demographicInformation?.country);
|
}: Props) {
|
||||||
const [group, setGroup] = useState<string | null>();
|
const [name, setName] = useState<string>();
|
||||||
const [password, setPassword] = useState<string>();
|
const [email, setEmail] = useState<string>();
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>();
|
const [phone, setPhone] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [passportID, setPassportID] = useState<string>();
|
||||||
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
|
const [studentID, setStudentID] = useState<string>();
|
||||||
);
|
const [country, setCountry] = useState(user?.demographicInformation?.country);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [group, setGroup] = useState<string | null>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [password, setPassword] = useState<string>();
|
||||||
const [type, setType] = useState<Type>("student");
|
const [confirmPassword, setConfirmPassword] = useState<string>();
|
||||||
const [position, setPosition] = useState<string>();
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user?.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [position, setPosition] = useState<string>();
|
||||||
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
|
|
||||||
const {groups} = useEntitiesGroups();
|
const { groups } = useEntitiesGroups();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const createUser = () => {
|
const createUser = () => {
|
||||||
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
|
if (!name || name.trim().length === 0)
|
||||||
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
|
return toast.error("Please enter a valid name!");
|
||||||
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
|
if (!email || email.trim().length === 0)
|
||||||
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
|
return toast.error("Please enter a valid e-mail address!");
|
||||||
if (password !== confirmPassword) return toast.error("The passwords do not match!");
|
if (users.map((x) => x.email).includes(email.trim()))
|
||||||
|
return toast.error("That e-mail is already in use!");
|
||||||
|
if (!password || password.trim().length < 6)
|
||||||
|
return toast.error("Please enter a valid password!");
|
||||||
|
if (password !== confirmPassword)
|
||||||
|
return toast.error("The passwords do not match!");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
groupID: group,
|
groupID: group,
|
||||||
entity,
|
entity,
|
||||||
type,
|
type,
|
||||||
studentID: type === "student" ? studentID : undefined,
|
studentID: type === "student" ? studentID : undefined,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
passport_id: type === "student" ? passportID : undefined,
|
passport_id: type === "student" ? passportID : undefined,
|
||||||
phone,
|
phone,
|
||||||
country,
|
country,
|
||||||
position,
|
position,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post("/api/make_user", body)
|
.post("/api/make_user", body)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("That user has been created!");
|
toast.success("That user has been created!");
|
||||||
onFinish();
|
onFinish();
|
||||||
|
|
||||||
setName("");
|
setName("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setPhone("");
|
setPhone("");
|
||||||
setPassportID("");
|
setPassportID("");
|
||||||
setStudentID("");
|
setStudentID("");
|
||||||
setCountry(user?.demographicInformation?.country);
|
setCountry(user?.demographicInformation?.country);
|
||||||
setGroup(null);
|
setGroup(null);
|
||||||
setEntity((entities || [])[0]?.id || undefined);
|
setEntity((entities || [])[0]?.id || undefined);
|
||||||
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
|
setExpiryDate(
|
||||||
setIsExpiryDateEnabled(true);
|
user?.subscriptionExpirationDate
|
||||||
setType("student");
|
? moment(user?.subscriptionExpirationDate).toDate()
|
||||||
setPosition(undefined);
|
: null
|
||||||
})
|
);
|
||||||
.catch((error) => {
|
setIsExpiryDateEnabled(true);
|
||||||
const data = error?.response?.data;
|
setType("student");
|
||||||
if (!!data?.message) return toast.error(data.message);
|
setPosition(undefined);
|
||||||
toast.error("Something went wrong! Please try again later!");
|
})
|
||||||
})
|
.catch((error) => {
|
||||||
.finally(() => setIsLoading(false));
|
const data = error?.response?.data;
|
||||||
};
|
if (!!data?.message) return toast.error(data.message);
|
||||||
|
toast.error("Something went wrong! Please try again later!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
|
<Input
|
||||||
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
|
required
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="E-mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
|
<Input
|
||||||
<Input
|
type="password"
|
||||||
type="password"
|
name="password"
|
||||||
name="confirmPassword"
|
label="Password"
|
||||||
label="Confirm Password"
|
value={password}
|
||||||
value={confirmPassword}
|
onChange={setPassword}
|
||||||
onChange={setConfirmPassword}
|
placeholder="Password"
|
||||||
placeholder="ConfirmPassword"
|
required
|
||||||
required
|
/>
|
||||||
/>
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm Password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={setConfirmPassword}
|
||||||
|
placeholder="ConfirmPassword"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
Country *
|
||||||
</div>
|
</label>
|
||||||
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
label="Phone number"
|
||||||
|
value={phone}
|
||||||
|
onChange={setPhone}
|
||||||
|
placeholder="Phone number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
{type === "student" && (
|
{type === "student" && (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="passport_id"
|
name="passport_id"
|
||||||
label="Passport/National ID"
|
label="Passport/National ID"
|
||||||
onChange={setPassportID}
|
onChange={setPassportID}
|
||||||
value={passportID}
|
value={passportID}
|
||||||
placeholder="National ID or Passport number"
|
placeholder="National ID or Passport number"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
|
<Input
|
||||||
</>
|
type="text"
|
||||||
)}
|
name="studentID"
|
||||||
|
label="Student ID"
|
||||||
|
onChange={setStudentID}
|
||||||
|
value={studentID}
|
||||||
|
placeholder="Student ID"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<Select
|
Entity
|
||||||
defaultValue={{value: (entities || [])[0]?.id, label: (entities || [])[0]?.label}}
|
</label>
|
||||||
options={entities.map((e) => ({value: e.id, label: e.label}))}
|
<Select
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
defaultValue={{
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
value: (entities || [])[0]?.id,
|
||||||
/>
|
label: (entities || [])[0]?.label,
|
||||||
</div>
|
}}
|
||||||
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{["corporate", "mastercorporate"].includes(type) && (
|
{["corporate", "mastercorporate"].includes(type) && (
|
||||||
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
<Input
|
||||||
)}
|
type="text"
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
onChange={setPosition}
|
||||||
|
value={position}
|
||||||
|
placeholder="Department"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<Select
|
Classroom
|
||||||
options={groups.filter((x) => x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))}
|
</label>
|
||||||
onChange={(e) => setGroup(e?.value || undefined)}
|
<Select
|
||||||
isClearable
|
options={groups
|
||||||
/>
|
.filter((x) => x.entity?.id === entity)
|
||||||
</div>
|
.map((g) => ({ value: g.id, label: g.name }))}
|
||||||
|
onChange={(e) => setGroup(e?.value || undefined)}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col gap-4",
|
"flex flex-col gap-4",
|
||||||
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
|
!checkAccess(user, [
|
||||||
)}>
|
"developer",
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
"admin",
|
||||||
{user && (
|
"corporate",
|
||||||
<select
|
"mastercorporate",
|
||||||
defaultValue="student"
|
]) && "col-span-2"
|
||||||
value={type}
|
)}
|
||||||
onChange={(e) => setType(e.target.value as Type)}
|
>
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
Type
|
||||||
.filter((x) => {
|
</label>
|
||||||
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
{user && (
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
<select
|
||||||
})
|
defaultValue="student"
|
||||||
.map((type) => (
|
value={type}
|
||||||
<option key={type} value={type}>
|
onChange={(e) => setType(e.target.value as Type)}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
</option>
|
>
|
||||||
))}
|
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||||
</select>
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
)}
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
</div>
|
acc.push(x);
|
||||||
|
return acc;
|
||||||
|
}, [])}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{user &&
|
||||||
<>
|
checkAccess(user, [
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
"developer",
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
"admin",
|
||||||
<Checkbox
|
"corporate",
|
||||||
isChecked={isExpiryDateEnabled}
|
"mastercorporate",
|
||||||
onChange={setIsExpiryDateEnabled}
|
]) && (
|
||||||
disabled={!!user?.subscriptionExpirationDate}>
|
<>
|
||||||
Enabled
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
</Checkbox>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
</div>
|
Expiry Date
|
||||||
{isExpiryDateEnabled && (
|
</label>
|
||||||
<ReactDatePicker
|
<Checkbox
|
||||||
className={clsx(
|
isChecked={isExpiryDateEnabled}
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
onChange={setIsExpiryDateEnabled}
|
||||||
"hover:border-mti-purple tooltip",
|
disabled={!!user?.subscriptionExpirationDate}
|
||||||
"transition duration-300 ease-in-out",
|
>
|
||||||
)}
|
Enabled
|
||||||
filterDate={(date) =>
|
</Checkbox>
|
||||||
moment(date).isAfter(new Date()) &&
|
</div>
|
||||||
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
|
{isExpiryDateEnabled && (
|
||||||
}
|
<ReactDatePicker
|
||||||
dateFormat="dd/MM/yyyy"
|
className={clsx(
|
||||||
selected={expiryDate}
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
onChange={(date) => setExpiryDate(date)}
|
"hover:border-mti-purple tooltip",
|
||||||
/>
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
</>
|
filterDate={(date) =>
|
||||||
)}
|
moment(date).isAfter(new Date()) &&
|
||||||
</div>
|
(user?.subscriptionExpirationDate
|
||||||
</div>
|
? moment(date).isBefore(
|
||||||
|
user?.subscriptionExpirationDate
|
||||||
|
)
|
||||||
|
: true)
|
||||||
|
}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={expiryDate}
|
||||||
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
|
<Button
|
||||||
Create User
|
onClick={createUser}
|
||||||
</Button>
|
isLoading={isLoading}
|
||||||
</div>
|
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
|
||||||
);
|
>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -13,267 +13,268 @@ import moment from "moment";
|
|||||||
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
setIsLoading: (isLoading: boolean) => void;
|
setIsLoading: (isLoading: boolean) => void;
|
||||||
mutateUser: KeyedMutator<User>;
|
mutateUser: KeyedMutator<User>;
|
||||||
sendEmailVerification: typeof sendEmailVerification;
|
sendEmailVerification: typeof sendEmailVerification;
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableDurations = {
|
const availableDurations = {
|
||||||
"1_month": { label: "1 Month", number: 1 },
|
"1_month": { label: "1 Month", number: 1 },
|
||||||
"3_months": { label: "3 Months", number: 3 },
|
"3_months": { label: "3 Months", number: 3 },
|
||||||
"6_months": { label: "6 Months", number: 6 },
|
"6_months": { label: "6 Months", number: 6 },
|
||||||
"12_months": { label: "12 Months", number: 12 },
|
"12_months": { label: "12 Months", number: 12 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RegisterCorporate({
|
export default function RegisterCorporate({
|
||||||
isLoading,
|
isLoading,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
mutateUser,
|
mutateUser,
|
||||||
sendEmailVerification,
|
sendEmailVerification,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||||
|
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [companyUsers, setCompanyUsers] = useState(0);
|
const [companyUsers, setCompanyUsers] = useState(0);
|
||||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||||
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers({ type: "agent" });
|
||||||
|
|
||||||
const onSuccess = () =>
|
const onSuccess = () =>
|
||||||
toast.success(
|
toast.success(
|
||||||
"An e-mail has been sent, please make sure to check your spam folder!",
|
"An e-mail has been sent, please make sure to check your spam folder!"
|
||||||
);
|
);
|
||||||
|
|
||||||
const onError = (e: Error) => {
|
const onError = (e: Error) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong, please logout and re-login.", {
|
toast.error("Something went wrong, please logout and re-login.", {
|
||||||
toastId: "send-verify-error",
|
toastId: "send-verify-error",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = (e: any) => {
|
const register = (e: any) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (confirmPassword !== password) {
|
if (confirmPassword !== password) {
|
||||||
toast.error("Your passwords do not match!", {
|
toast.error("Your passwords do not match!", {
|
||||||
toastId: "password-not-match",
|
toastId: "password-not-match",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post("/api/register", {
|
.post("/api/register", {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
type: "corporate",
|
type: "corporate",
|
||||||
profilePicture: "/defaultAvatar.png",
|
profilePicture: "/defaultAvatar.png",
|
||||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||||
corporateInformation: {
|
corporateInformation: {
|
||||||
monthlyDuration: subscriptionDuration,
|
monthlyDuration: subscriptionDuration,
|
||||||
referralAgent,
|
referralAgent,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
mutateUser(response.data.user).then(() =>
|
mutateUser(response.data.user).then(() =>
|
||||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
sendEmailVerification(setIsLoading, onSuccess, onError)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error.response.data);
|
console.log(error.response.data);
|
||||||
|
|
||||||
if (error.response.status === 401) {
|
if (error.response.status === 401) {
|
||||||
toast.error("There is already a user with that e-mail!");
|
toast.error("There is already a user with that e-mail!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.response.status === 400) {
|
if (error.response.status === 400) {
|
||||||
toast.error("The provided code is invalid!");
|
toast.error("The provided code is invalid!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error("There was something wrong, please try again!");
|
toast.error("There was something wrong, please try again!");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="flex w-full flex-col items-center gap-4"
|
className="flex w-full flex-col items-center gap-4"
|
||||||
onSubmit={register}
|
onSubmit={register}
|
||||||
>
|
>
|
||||||
<div className="flex w-full gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
onChange={(e) => setName(e)}
|
onChange={(e) => setName(e)}
|
||||||
placeholder="Enter your name"
|
placeholder="Enter your name"
|
||||||
defaultValue={name}
|
defaultValue={name}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
onChange={(e) => setEmail(e.toLowerCase())}
|
onChange={(e) => setEmail(e.toLowerCase())}
|
||||||
placeholder="Enter email address"
|
placeholder="Enter email address"
|
||||||
defaultValue={email}
|
defaultValue={email}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
onChange={(e) => setPassword(e)}
|
onChange={(e) => setPassword(e)}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
defaultValue={password}
|
defaultValue={password}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
onChange={(e) => setConfirmPassword(e)}
|
onChange={(e) => setConfirmPassword(e)}
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
defaultValue={confirmPassword}
|
defaultValue={confirmPassword}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider className="!my-2 w-full" />
|
<Divider className="!my-2 w-full" />
|
||||||
|
|
||||||
<div className="flex w-full gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="companyName"
|
name="companyName"
|
||||||
onChange={(e) => setCompanyName(e)}
|
onChange={(e) => setCompanyName(e)}
|
||||||
placeholder="Corporate name"
|
placeholder="Corporate name"
|
||||||
label="Corporate name"
|
label="Corporate name"
|
||||||
defaultValue={companyName}
|
defaultValue={companyName}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
name="companyUsers"
|
name="companyUsers"
|
||||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||||
label="Number of users"
|
label="Number of users"
|
||||||
defaultValue={companyUsers}
|
defaultValue={companyUsers}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
Referral *
|
Referral *
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "No referral" },
|
{ value: "", label: "No referral" },
|
||||||
...users
|
...users.map((x) => ({
|
||||||
.filter((u) => u.type === "agent")
|
value: x.id,
|
||||||
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
|
label: `${x.name} - ${x.email}`,
|
||||||
]}
|
})),
|
||||||
defaultValue={{ value: "", label: "No referral" }}
|
]}
|
||||||
onChange={(value) => setReferralAgent(value?.value)}
|
defaultValue={{ value: "", label: "No referral" }}
|
||||||
styles={{
|
onChange={(value) => setReferralAgent(value?.value)}
|
||||||
control: (styles) => ({
|
styles={{
|
||||||
...styles,
|
control: (styles) => ({
|
||||||
paddingLeft: "4px",
|
...styles,
|
||||||
border: "none",
|
paddingLeft: "4px",
|
||||||
outline: "none",
|
border: "none",
|
||||||
":focus": {
|
outline: "none",
|
||||||
outline: "none",
|
":focus": {
|
||||||
},
|
outline: "none",
|
||||||
}),
|
},
|
||||||
option: (styles, state) => ({
|
}),
|
||||||
...styles,
|
option: (styles, state) => ({
|
||||||
backgroundColor: state.isFocused
|
...styles,
|
||||||
? "#D5D9F0"
|
backgroundColor: state.isFocused
|
||||||
: state.isSelected
|
? "#D5D9F0"
|
||||||
? "#7872BF"
|
: state.isSelected
|
||||||
: "white",
|
? "#7872BF"
|
||||||
color: state.isFocused ? "black" : styles.color,
|
: "white",
|
||||||
}),
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}}
|
}),
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
Subscription Duration *
|
Subscription Duration *
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||||
options={Object.keys(availableDurations).map((value) => ({
|
options={Object.keys(availableDurations).map((value) => ({
|
||||||
value,
|
value,
|
||||||
label:
|
label:
|
||||||
availableDurations[value as keyof typeof availableDurations]
|
availableDurations[value as keyof typeof availableDurations]
|
||||||
.label,
|
.label,
|
||||||
}))}
|
}))}
|
||||||
defaultValue={{
|
defaultValue={{
|
||||||
value: "1_month",
|
value: "1_month",
|
||||||
label: availableDurations["1_month"].label,
|
label: availableDurations["1_month"].label,
|
||||||
}}
|
}}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setSubscriptionDuration(
|
setSubscriptionDuration(
|
||||||
value
|
value
|
||||||
? availableDurations[
|
? availableDurations[
|
||||||
value.value as keyof typeof availableDurations
|
value.value as keyof typeof availableDurations
|
||||||
].number
|
].number
|
||||||
: 1,
|
: 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
":focus": {
|
":focus": {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused
|
backgroundColor: state.isFocused
|
||||||
? "#D5D9F0"
|
? "#D5D9F0"
|
||||||
: state.isSelected
|
: state.isSelected
|
||||||
? "#7872BF"
|
? "#7872BF"
|
||||||
: "white",
|
: "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-start gap-4">
|
<div className="flex w-full flex-col items-start gap-4">
|
||||||
{renderCheckbox()}
|
{renderCheckbox()}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full lg:mt-8"
|
className="w-full lg:mt-8"
|
||||||
color="purple"
|
color="purple"
|
||||||
disabled={
|
disabled={
|
||||||
isLoading ||
|
isLoading ||
|
||||||
!email ||
|
!email ||
|
||||||
!name ||
|
!name ||
|
||||||
!password ||
|
!password ||
|
||||||
!confirmPassword ||
|
!confirmPassword ||
|
||||||
password !== confirmPassword ||
|
password !== confirmPassword ||
|
||||||
!companyName ||
|
!companyName ||
|
||||||
companyUsers <= 0
|
companyUsers <= 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Create account
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import client from "@/lib/mongodb";
|
import client from "@/lib/mongodb";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { flatten, map } from "lodash";
|
import { flatten } from "lodash";
|
||||||
import { AccessType, Exam } from "@/interfaces/exam";
|
import { AccessType, Exam } from "@/interfaces/exam";
|
||||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
import { requestUser } from "../../../utils/api";
|
import { requestUser } from "../../../utils/api";
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('response', response.data);
|
||||||
res.status(response.status).json(response.data);
|
res.status(response.status).json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
res.status(500).json({ message: 'An unexpected error occurred' });
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,250 +1,325 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {ToastContainer} from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import {Radio, RadioGroup} from "@headlessui/react";
|
import { Radio, RadioGroup } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {findAllowedEntities} from "@/utils/permissions";
|
import {
|
||||||
import {User} from "@/interfaces/user";
|
findAllowedEntities,
|
||||||
|
findAllowedEntitiesSomePermissions,
|
||||||
|
groupAllowedEntitiesByPermissions,
|
||||||
|
} from "@/utils/permissions";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import ExamEditorStore from "@/stores/examEditor/types";
|
import ExamEditorStore from "@/stores/examEditor/types";
|
||||||
import ExamEditor from "@/components/ExamEditor";
|
import ExamEditor from "@/components/ExamEditor";
|
||||||
import {mapBy, redirect, serialize} from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
import {requestUser} from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {getExam} from "@/utils/exams.be";
|
import { getExam } from "@/utils/exams.be";
|
||||||
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam";
|
import {
|
||||||
import {useEffect, useState} from "react";
|
Exam,
|
||||||
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
Exercise,
|
||||||
import {isAdmin} from "@/utils/users";
|
InteractiveSpeakingExercise,
|
||||||
|
ListeningPart,
|
||||||
|
SpeakingExercise,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {EntityWithRoles} from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
type Permission = {[key in Module]: boolean};
|
type Permission = { [key in Module]: boolean };
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
const user = await requestUser(req, res);
|
async ({ req, res, query }) => {
|
||||||
if (!user) return redirect("/login");
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/");
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, "id");
|
const entityIDs = mapBy(user.entities, "id");
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
|
|
||||||
|
|
||||||
const permissions: Permission = {
|
const entities = await getEntitiesWithRoles(
|
||||||
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0,
|
isAdmin(user) ? undefined : entityIDs
|
||||||
listening: findAllowedEntities(user, entities, `generate_listening`).length > 0,
|
);
|
||||||
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0,
|
|
||||||
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
|
|
||||||
level: findAllowedEntities(user, entities, `generate_level`).length > 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy");
|
const generatePermissions = groupAllowedEntitiesByPermissions(
|
||||||
console.log(entitiesAllowEditPrivacy);
|
user,
|
||||||
|
entities,
|
||||||
|
[
|
||||||
|
"generate_reading",
|
||||||
|
"generate_listening",
|
||||||
|
"generate_writing",
|
||||||
|
"generate_speaking",
|
||||||
|
"generate_level",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/");
|
const permissions: Permission = {
|
||||||
|
reading: generatePermissions["generate_reading"].length > 0,
|
||||||
|
listening: generatePermissions["generate_listening"].length > 0,
|
||||||
|
writing: generatePermissions["generate_writing"].length > 0,
|
||||||
|
speaking: generatePermissions["generate_speaking"].length > 0,
|
||||||
|
level: generatePermissions["generate_level"].length > 0,
|
||||||
|
};
|
||||||
|
|
||||||
const {id, module: examModule} = query as {id?: string; module?: Module};
|
const {
|
||||||
if (!id || !examModule) return {props: serialize({user, permissions})};
|
["update_exam_privacy"]: entitiesAllowEditPrivacy,
|
||||||
|
["create_confidential_exams"]: entitiesAllowConfExams,
|
||||||
|
["create_public_exams"]: entitiesAllowPublicExams,
|
||||||
|
} = groupAllowedEntitiesByPermissions(user, entities, [
|
||||||
|
"update_exam_privacy",
|
||||||
|
"create_confidential_exams",
|
||||||
|
"create_public_exams",
|
||||||
|
]);
|
||||||
|
|
||||||
//if (!permissions[module]) return redirect("/generation")
|
if (Object.keys(permissions).every((p) => !permissions[p as Module]))
|
||||||
|
return redirect("/");
|
||||||
|
|
||||||
const exam = await getExam(examModule, id);
|
const { id, module: examModule } = query as {
|
||||||
if (!exam) return redirect("/generation");
|
id?: string;
|
||||||
|
module?: Module;
|
||||||
|
};
|
||||||
|
if (!id || !examModule) return { props: serialize({ user, permissions }) };
|
||||||
|
|
||||||
return {
|
//if (!permissions[module]) return redirect("/generation")
|
||||||
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
|
|
||||||
};
|
const exam = await getExam(examModule, id);
|
||||||
}, sessionOptions);
|
if (!exam) return redirect("/generation");
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: serialize({
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
exam,
|
||||||
|
examModule,
|
||||||
|
permissions,
|
||||||
|
entitiesAllowEditPrivacy,
|
||||||
|
entitiesAllowConfExams,
|
||||||
|
entitiesAllowPublicExams,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sessionOptions
|
||||||
|
);
|
||||||
|
|
||||||
export default function Generation({
|
export default function Generation({
|
||||||
id,
|
id,
|
||||||
user,
|
user,
|
||||||
exam,
|
exam,
|
||||||
examModule,
|
examModule,
|
||||||
permissions,
|
permissions,
|
||||||
entitiesAllowEditPrivacy,
|
entitiesAllowEditPrivacy,
|
||||||
|
entitiesAllowConfExams,
|
||||||
|
entitiesAllowPublicExams,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
user: User;
|
user: User;
|
||||||
exam?: Exam;
|
exam?: Exam;
|
||||||
examModule?: Module;
|
examModule?: Module;
|
||||||
permissions: Permission;
|
permissions: Permission;
|
||||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||||
|
entitiesAllowPublicExams: EntityWithRoles[];
|
||||||
|
entitiesAllowConfExams: EntityWithRoles[];
|
||||||
}) {
|
}) {
|
||||||
const {title, currentModule, modules, dispatch} = useExamEditorStore();
|
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
||||||
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined);
|
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
||||||
dispatch({type: "UPDATE_ROOT", payload: {updates}});
|
dispatch({ type: "UPDATE_ROOT", payload: { updates } });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id && exam && examModule) {
|
if (id && exam && examModule) {
|
||||||
if (examModule === "level" && exam.module === "level") {
|
if (examModule === "level" && exam.module === "level") {
|
||||||
setExamLevelParts(exam.parts.length);
|
setExamLevelParts(exam.parts.length);
|
||||||
}
|
}
|
||||||
updateRoot({currentModule: examModule});
|
updateRoot({ currentModule: examModule });
|
||||||
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}});
|
dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id, exam, module]);
|
}, [id, exam, module]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAvatars = async () => {
|
const fetchAvatars = async () => {
|
||||||
const response = await axios.get("/api/exam/avatars");
|
const response = await axios.get("/api/exam/avatars");
|
||||||
updateRoot({speakingAvatars: response.data});
|
updateRoot({ speakingAvatars: response.data });
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAvatars();
|
fetchAvatars();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// media cleanup on unmount
|
// media cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
const state = modules;
|
const state = modules;
|
||||||
|
|
||||||
if (state.writing.academic_url) {
|
if (state.writing.academic_url) {
|
||||||
URL.revokeObjectURL(state.writing.academic_url);
|
URL.revokeObjectURL(state.writing.academic_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.listening.sections.forEach((section) => {
|
state.listening.sections.forEach((section) => {
|
||||||
const listeningPart = section.state as ListeningPart;
|
const listeningPart = section.state as ListeningPart;
|
||||||
if (listeningPart.audio?.source) {
|
if (listeningPart.audio?.source) {
|
||||||
URL.revokeObjectURL(listeningPart.audio.source);
|
URL.revokeObjectURL(listeningPart.audio.source);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId: section.sectionId,
|
sectionId: section.sectionId,
|
||||||
module: "listening",
|
module: "listening",
|
||||||
field: "state",
|
field: "state",
|
||||||
value: {...listeningPart, audio: undefined},
|
value: { ...listeningPart, audio: undefined },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.listening.instructionsState.customInstructionsURL.startsWith("blob:")) {
|
if (
|
||||||
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL);
|
state.listening.instructionsState.customInstructionsURL.startsWith(
|
||||||
}
|
"blob:"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(
|
||||||
|
state.listening.instructionsState.customInstructionsURL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
state.speaking.sections.forEach((section) => {
|
state.speaking.sections.forEach((section) => {
|
||||||
const sectionState = section.state as Exercise;
|
const sectionState = section.state as Exercise;
|
||||||
if (sectionState.type === "speaking") {
|
if (sectionState.type === "speaking") {
|
||||||
const speakingExercise = sectionState as SpeakingExercise;
|
const speakingExercise = sectionState as SpeakingExercise;
|
||||||
URL.revokeObjectURL(speakingExercise.video_url);
|
URL.revokeObjectURL(speakingExercise.video_url);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId: section.sectionId,
|
sectionId: section.sectionId,
|
||||||
module: "listening",
|
module: "listening",
|
||||||
field: "state",
|
field: "state",
|
||||||
value: {...speakingExercise, video_url: undefined},
|
value: { ...speakingExercise, video_url: undefined },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (sectionState.type === "interactiveSpeaking") {
|
if (sectionState.type === "interactiveSpeaking") {
|
||||||
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
const interactiveSpeaking =
|
||||||
interactiveSpeaking.prompts.forEach((prompt) => {
|
sectionState as InteractiveSpeakingExercise;
|
||||||
URL.revokeObjectURL(prompt.video_url);
|
interactiveSpeaking.prompts.forEach((prompt) => {
|
||||||
});
|
URL.revokeObjectURL(prompt.video_url);
|
||||||
dispatch({
|
});
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
dispatch({
|
||||||
payload: {
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
sectionId: section.sectionId,
|
payload: {
|
||||||
module: "listening",
|
sectionId: section.sectionId,
|
||||||
field: "state",
|
module: "listening",
|
||||||
value: {
|
field: "state",
|
||||||
...interactiveSpeaking,
|
value: {
|
||||||
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})),
|
...interactiveSpeaking,
|
||||||
},
|
prompts: interactiveSpeaking.prompts.map((p) => ({
|
||||||
},
|
...p,
|
||||||
});
|
video_url: undefined,
|
||||||
}
|
})),
|
||||||
});
|
},
|
||||||
dispatch({type: "FULL_RESET"});
|
},
|
||||||
};
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}
|
||||||
}, []);
|
});
|
||||||
|
dispatch({ type: "FULL_RESET" });
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Exam Generation | EnCoach</title>
|
<title>Exam Generation | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-semibold">Exam Editor</h1>
|
<h1 className="text-2xl font-semibold">Exam Editor</h1>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Insert a title here"
|
placeholder="Insert a title here"
|
||||||
name="title"
|
name="title"
|
||||||
label="Title"
|
label="Title"
|
||||||
onChange={(title) => updateRoot({title})}
|
onChange={(title) => updateRoot({ title })}
|
||||||
roundness="xl"
|
roundness="xl"
|
||||||
value={title}
|
value={title}
|
||||||
defaultValue={title}
|
defaultValue={title}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<RadioGroup
|
Module
|
||||||
value={currentModule}
|
</label>
|
||||||
onChange={(currentModule) => updateRoot({currentModule})}
|
<RadioGroup
|
||||||
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between">
|
value={currentModule}
|
||||||
{[...MODULE_ARRAY]
|
onChange={(currentModule) => updateRoot({ currentModule })}
|
||||||
.filter((m) => permissions[m])
|
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
|
||||||
.map((x) => (
|
>
|
||||||
<Radio value={x} key={x}>
|
{[...MODULE_ARRAY].reduce((acc, x) => {
|
||||||
{({checked}) => (
|
if (permissions[x])
|
||||||
<span
|
acc.push(
|
||||||
className={clsx(
|
<Radio value={x} key={x}>
|
||||||
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
{({ checked }) => (
|
||||||
"transition duration-300 ease-in-out",
|
<span
|
||||||
x === "reading" &&
|
className={clsx(
|
||||||
(!checked
|
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
? "bg-white border-mti-gray-platinum"
|
"transition duration-300 ease-in-out",
|
||||||
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
x === "reading" &&
|
||||||
x === "listening" &&
|
(!checked
|
||||||
(!checked
|
? "bg-white border-mti-gray-platinum"
|
||||||
? "bg-white border-mti-gray-platinum"
|
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||||
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
x === "listening" &&
|
||||||
x === "writing" &&
|
(!checked
|
||||||
(!checked
|
? "bg-white border-mti-gray-platinum"
|
||||||
? "bg-white border-mti-gray-platinum"
|
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||||
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
x === "writing" &&
|
||||||
x === "speaking" &&
|
(!checked
|
||||||
(!checked
|
? "bg-white border-mti-gray-platinum"
|
||||||
? "bg-white border-mti-gray-platinum"
|
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||||
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
x === "speaking" &&
|
||||||
x === "level" &&
|
(!checked
|
||||||
(!checked
|
? "bg-white border-mti-gray-platinum"
|
||||||
? "bg-white border-mti-gray-platinum"
|
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||||
: "bg-ielts-level/70 border-ielts-level text-white"),
|
x === "level" &&
|
||||||
)}>
|
(!checked
|
||||||
{capitalize(x)}
|
? "bg-white border-mti-gray-platinum"
|
||||||
</span>
|
: "bg-ielts-level/70 border-ielts-level text-white")
|
||||||
)}
|
)}
|
||||||
</Radio>
|
>
|
||||||
))}
|
{capitalize(x)}
|
||||||
</RadioGroup>
|
</span>
|
||||||
</div>
|
)}
|
||||||
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
|
</Radio>
|
||||||
</>
|
);
|
||||||
)}
|
return acc;
|
||||||
</>
|
}, [] as JSX.Element[])}
|
||||||
);
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<ExamEditor
|
||||||
|
levelParts={examLevelParts}
|
||||||
|
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
|
||||||
|
entitiesAllowConfExams={entitiesAllowConfExams}
|
||||||
|
entitiesAllowPublicExams={entitiesAllowPublicExams}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export default function History({
|
|||||||
list={filteredStats}
|
list={filteredStats}
|
||||||
renderCard={customContent}
|
renderCard={customContent}
|
||||||
searchFields={[]}
|
searchFields={[]}
|
||||||
pageSize={30}
|
pageSize={25}
|
||||||
className="lg:!grid-cols-3"
|
className="lg:!grid-cols-3"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -203,6 +203,32 @@ const Training: React.FC<{
|
|||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
|
<RecordFilter
|
||||||
|
entities={entities}
|
||||||
|
user={user}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
filterState={{ filter: filter, setFilter: setFilter }}
|
||||||
|
assignments={false}
|
||||||
|
>
|
||||||
|
{user.type === "student" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="font-semibold text-2xl">
|
||||||
|
Generate New Training Material
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
|
||||||
|
"transition duration-300 ease-in-out"
|
||||||
|
)}
|
||||||
|
onClick={handleNewTrainingContent}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</RecordFilter>
|
||||||
<>
|
<>
|
||||||
{isNewContentLoading || areRecordsLoading ? (
|
{isNewContentLoading || areRecordsLoading ? (
|
||||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||||
@@ -215,38 +241,10 @@ const Training: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RecordFilter
|
|
||||||
entities={entities}
|
|
||||||
user={user}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
filterState={{ filter: filter, setFilter: setFilter }}
|
|
||||||
assignments={false}
|
|
||||||
>
|
|
||||||
{user.type === "student" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="font-semibold text-2xl">
|
|
||||||
Generate New Training Material
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
|
|
||||||
"transition duration-300 ease-in-out"
|
|
||||||
)}
|
|
||||||
onClick={handleNewTrainingContent}
|
|
||||||
>
|
|
||||||
<FaPlus />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</RecordFilter>
|
|
||||||
{trainingContent.length == 0 && (
|
{trainingContent.length == 0 && (
|
||||||
<div className="flex flex-grow justify-center items-center">
|
|
||||||
<span className="font-semibold ml-1">
|
<span className="font-semibold ml-1">
|
||||||
No training content to display...
|
No training content to display...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!areRecordsLoading &&
|
{!areRecordsLoading &&
|
||||||
groupedByTrainingContent &&
|
groupedByTrainingContent &&
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
entities,
|
entities,
|
||||||
"view_student_performance"
|
"view_student_performance"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allowedEntities.length === 0) return redirect("/");
|
if (allowedEntities.length === 0) return redirect("/");
|
||||||
|
|
||||||
const students = await (checkAccess(user, ["admin", "developer"])
|
const students = await (checkAccess(user, ["admin", "developer"])
|
||||||
@@ -58,10 +57,11 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
|
|||||||
const performanceStudents = students.map((u) => ({
|
const performanceStudents = students.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||||
entitiesLabel: mapBy(u.entities, "id")
|
entitiesLabel: (u.entities || []).reduce((acc, curr, idx) => {
|
||||||
.map((id) => entities.find((e) => e.id === id)?.label)
|
const entity = entities.find((e) => e.id === curr.id);
|
||||||
.filter((e) => !!e)
|
if (idx === 0) return entity ? entity.label : "";
|
||||||
.join(", "),
|
return acc + (entity ? `${entity.label}` : "");
|
||||||
|
}, ""),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type RootActions =
|
|||||||
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } |
|
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } |
|
||||||
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
|
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
|
||||||
{ type: 'FINALIZE_MODULE_SOLUTIONS' } |
|
{ type: 'FINALIZE_MODULE_SOLUTIONS' } |
|
||||||
{ type: 'UPDATE_EXAMS'}
|
{ type: 'UPDATE_EXAMS' }
|
||||||
|
|
||||||
|
|
||||||
export type Action = RootActions | SessionActions;
|
export type Action = RootActions | SessionActions;
|
||||||
@@ -130,7 +130,7 @@ export const rootReducer = (
|
|||||||
if (state.flags.reviewAll) {
|
if (state.flags.reviewAll) {
|
||||||
const notLastModule = state.moduleIndex < state.selectedModules.length - 1;
|
const notLastModule = state.moduleIndex < state.selectedModules.length - 1;
|
||||||
const moduleIndex = notLastModule ? state.moduleIndex + 1 : -1;
|
const moduleIndex = notLastModule ? state.moduleIndex + 1 : -1;
|
||||||
|
|
||||||
if (notLastModule) {
|
if (notLastModule) {
|
||||||
return {
|
return {
|
||||||
questionIndex: 0,
|
questionIndex: 0,
|
||||||
@@ -152,7 +152,7 @@ export const rootReducer = (
|
|||||||
moduleIndex: -1
|
moduleIndex: -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
case 'UPDATE_EXAMS': {
|
case 'UPDATE_EXAMS': {
|
||||||
const exams = state.exams.map((e) => updateExamWithUserSolutions(e, state.userSolutions));
|
const exams = state.exams.map((e) => updateExamWithUserSolutions(e, state.userSolutions));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import client from "@/lib/mongodb";
|
|||||||
import { EntityWithRoles, WithEntities } from "@/interfaces/entity";
|
import { EntityWithRoles, WithEntities } from "@/interfaces/entity";
|
||||||
import { getEntity } from "./entities.be";
|
import { getEntity } from "./entities.be";
|
||||||
import { getRole } from "./roles.be";
|
import { getRole } from "./roles.be";
|
||||||
import { findAllowedEntities, groupAllowedEntitiesByPermissions } from "./permissions";
|
import { groupAllowedEntitiesByPermissions } from "./permissions";
|
||||||
import { mapBy } from ".";
|
import { mapBy } from ".";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
@@ -266,12 +266,13 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
|
|||||||
'view_corporates',
|
'view_corporates',
|
||||||
'view_mastercorporates',
|
'view_mastercorporates',
|
||||||
]);
|
]);
|
||||||
|
console.log(mapBy(allowedStudentEntities, 'id'))
|
||||||
const [student, teacher, corporate, mastercorporate] = await Promise.all([
|
const [student, teacher, corporate, mastercorporate] = await Promise.all([
|
||||||
countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
|
countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
|
||||||
countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
|
countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
|
||||||
countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
|
countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
|
||||||
countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
|
countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
|
||||||
])
|
])
|
||||||
|
console.log(student)
|
||||||
return { student, teacher, corporate, mastercorporate }
|
return { student, teacher, corporate, mastercorporate }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user