bugsfixed and design changes for generation 13'' screen

This commit is contained in:
José Lima
2025-02-23 18:47:57 +00:00
parent fec3b51553
commit 340ff5a30a
29 changed files with 2292 additions and 1680 deletions

View File

@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
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">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
import clsx from "clsx";
import SectionRenderer from "./SectionRenderer";
import Checkbox from "../Low/Checkbox";
import Input from "../Low/Input";
import Select from "../Low/Select";
import { capitalize } from "lodash";
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 { ModuleState, SectionState } from "@/stores/examEditor/types";
import { Module } from "@/interfaces";
@@ -21,13 +20,36 @@ import Button from "../Low/Button";
import ResetModule from "./Standalone/ResetModule";
import ListeningInstructions from "./Standalone/ListeningInstructions";
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<{
levelParts?: number;
entitiesAllowEditPrivacy: EntityWithRoles[];
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => {
entitiesAllowConfExams: EntityWithRoles[];
entitiesAllowPublicExams: EntityWithRoles[];
}> = ({
levelParts = 0,
entitiesAllowEditPrivacy = [],
entitiesAllowConfExams = [],
entitiesAllowPublicExams = [],
}) => {
const { currentModule, dispatch } = useExamEditorStore();
const {
sections,
@@ -111,7 +133,10 @@ const ExamEditor: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId);
const sectionIds = useMemo(
() => sections.map((section) => section.sectionId),
[sections]
);
const updateModule = useCallback(
(updates: Partial<ModuleState>) => {
@@ -120,29 +145,42 @@ const ExamEditor: React.FC<{
[dispatch]
);
const toggleSection = (sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
toast.error("Include at least one section!");
return;
const toggleSection = useCallback(
(sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
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> = {
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) => {
const updateLevelParts = useCallback((parts: number) => {
setNumberOfLevelParts(parts);
};
}, []);
return (
<>
@@ -161,9 +199,14 @@ const ExamEditor: React.FC<{
setNumberOfLevelParts={setNumberOfLevelParts}
/>
)}
<div className="flex gap-4 w-full items-center -xl:flex-col">
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3">
<div
className={clsx(
"flex gap-4 w-full",
sectionLabels.length > 3 ? "-2xl:flex-col" : "-xl:flex-col"
)}
>
<div className="flex flex-row gap-3">
<div className="flex flex-col gap-3 ">
<label className="font-normal text-base text-mti-gray-dim">
Timer
</label>
@@ -176,19 +219,16 @@ const ExamEditor: React.FC<{
})
}
value={minTimer}
className="max-w-[300px]"
className="max-w-[125px] min-w-[100px] w-min"
/>
</div>
<div className="flex flex-col gap-3 flex-grow">
<div className="flex flex-col gap-3 ">
<label className="font-normal text-base text-mti-gray-dim">
Difficulty
</label>
<Select
isMulti={true}
options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
options={DIFFICULTIES}
onChange={(values) => {
const selectedDifficulties = values
? 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">
{sectionLabels[0].label.split(" ")[0]}
</label>
<div className="flex flex-row gap-8">
<div className="flex flex-row gap-3">
{sectionLabels.map(({ id, label }) => (
<span
key={id}
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",
sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
@@ -246,22 +286,24 @@ const ExamEditor: React.FC<{
/>
</div>
)}
</div>
<div className="flex flex-row gap-3 w-64">
<Select
label="Access Type"
options={ACCESSTYPE.map((item) => ({
value: item,
label: capitalize(item),
}))}
onChange={(value) => {
if (value?.value) {
updateModule({ access: value.value! as AccessType });
<div className="max-w-[200px] w-full">
<Select
label="Access Type"
disabled={
accessTypeOptions.length === 0 ||
entitiesAllowEditPrivacy.length === 0
}
}}
value={{ value: access, label: capitalize(access) }}
/>
options={accessTypeOptions}
onChange={(value) => {
if (value?.value) {
updateModule({ access: value.value! as AccessType });
}
}}
value={{ value: access, label: capitalize(access) }}
/>
</div>
</div>
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">
@@ -286,7 +328,7 @@ const ExamEditor: React.FC<{
Reset Module
</Button>
</div>
<div className="flex flex-row gap-8 -2xl:flex-col">
<div className="flex flex-row gap-8 -xl:flex-col">
<Settings />
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import {
import axios from "axios";
import { capitalize } from "lodash";
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 { useListSearch } from "@/hooks/useListSearch";
import Modal from "@/components/Modal";

View File

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

View File

@@ -1,256 +1,341 @@
import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import usePackages from "@/hooks/usePackages";
import {Module} from "@/interfaces";
import {Package} from "@/interfaces/paypal";
import {User} from "@/interfaces/user";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import { Module } from "@/interfaces";
import { Package } from "@/interfaces/paypal";
import { User } from "@/interfaces/user";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios";
import {capitalize} from "lodash";
import {useState} from "react";
import {BsPencil, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
import { capitalize } from "lodash";
import { useCallback, useMemo, useState } from "react";
import { BsPencil, BsTrash } from "react-icons/bs";
import { toast } from "react-toastify";
import Select from "react-select";
import {CURRENCIES} from "@/resources/paypal";
import { CURRENCIES } from "@/resources/paypal";
import Button from "@/components/Low/Button";
const CLASSES: {[key in Module]: string} = {
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
const CLASSES: { [key in Module]: string } = {
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
};
const columnHelper = createColumnHelper<Package>();
type DurationUnit = "days" | "weeks" | "months" | "years";
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
const [duration, setDuration] = useState(pack?.duration || 1);
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
const currencyOptions = CURRENCIES.map(({ label, currency }) => ({
value: currency,
label,
}));
const [price, setPrice] = useState(pack?.price || 0);
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
function PackageCreator({
pack,
onClose,
}: {
pack?: Package;
onClose: () => void;
}) {
const [duration, setDuration] = useState(pack?.duration || 1);
const [unit, setUnit] = useState<DurationUnit>(
pack?.duration_unit || "months"
);
const submit = () => {
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
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!");
});
};
const [price, setPrice] = useState(pack?.price || 0);
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
return (
<div className="flex flex-col gap-8 py-8">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
<div className="flex gap-4 items-center">
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
const submit = useCallback(() => {
(pack ? axios.patch : axios.post)(
pack ? `/api/packages/${pack.id}` : "/api/packages",
{
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!");
});
}, [duration, unit, price, currency, pack, onClose]);
<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={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
defaultValue={{value: currency || "EUR", 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"}}
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 flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
<div className="flex gap-4 items-center">
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
<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={[
{value: "days", label: "Days"},
{value: "weeks", label: "Weeks"},
{value: "months", label: "Months"},
{value: "years", label: "Years"},
]}
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>
);
const currencyDefaultValue = useMemo(() => {
return {
value: currency || "EUR",
label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
};
}, [currency]);
return (
<div className="flex flex-col gap-8 py-8">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Price *
</label>
<div className="flex gap-4 items-center">
<Input
defaultValue={price}
name="price"
type="number"
onChange={(e) => setPrice(parseInt(e))}
/>
<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}
onChange={(value) => setCurrency(value?.value || "EUR")}
value={currencyDefaultValue}
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 flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Duration *
</label>
<div className="flex gap-4 items-center">
<Input
defaultValue={duration}
name="duration"
type="number"
onChange={(e) => setDuration(parseInt(e))}
/>
<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={[
{ value: "days", label: "Days" },
{ value: "weeks", label: "Weeks" },
{ value: "months", label: "Months" },
{ value: "years", label: "Years" },
]}
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}) {
const [isCreating, setIsCreating] = useState(false);
const [editingPackage, setEditingPackage] = useState<Package>();
export default function PackageList({ user }: { user: User }) {
const [isCreating, setIsCreating] = useState(false);
const [editingPackage, setEditingPackage] = useState<Package>();
const {packages, reload} = usePackages();
const { packages, reload } = usePackages();
const deletePackage = async (pack: Package) => {
if (!confirm(`Are you sure you want to delete this package?`)) return;
const deletePackage = useCallback(
async (pack: Package) => {
if (!confirm(`Are you sure you want to delete this package?`)) return;
axios
.delete(`/api/packages/${pack.id}`)
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Package not found!");
return;
}
axios
.delete(`/api/packages/${pack.id}`)
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Package not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
},
[reload]
);
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("duration", {
header: "Duration",
cell: (info) => (
<span>
{info.getValue()} {info.row.original.duration_unit}
</span>
),
}),
columnHelper.accessor("price", {
header: "Price",
cell: (info) => (
<span>
{info.getValue()} {info.row.original.currency}
</span>
),
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Package}}) => {
return (
<div className="flex gap-4">
{["developer", "admin"].includes(user.type) && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</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>
);
},
},
];
const defaultColumns = useMemo(
() => [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("duration", {
header: "Duration",
cell: (info) => (
<span>
{info.getValue()} {info.row.original.duration_unit}
</span>
),
}),
columnHelper.accessor("price", {
header: "Price",
cell: (info) => (
<span>
{info.getValue()} {info.row.original.currency}
</span>
),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Package } }) => {
return (
<div className="flex gap-4">
{["developer", "admin"].includes(user?.type) && (
<div
data-tip="Edit"
className="cursor-pointer tooltip"
onClick={() => setEditingPackage(row.original)}
>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</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({
data: packages,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const table = useReactTable({
data: packages,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const closeModal = () => {
setIsCreating(false);
setEditingPackage(undefined);
reload();
};
const closeModal = useCallback(() => {
setIsCreating(false);
setEditingPackage(undefined);
reload();
}, [reload]);
return (
<div className="w-full h-full rounded-xl">
<Modal
isOpen={isCreating || !!editingPackage}
onClose={closeModal}
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
<PackageCreator onClose={closeModal} pack={editingPackage} />
</Modal>
<table className="bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</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>
);
return (
<div className="w-full h-full rounded-xl">
<Modal
isOpen={isCreating || !!editingPackage}
onClose={closeModal}
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
>
<PackageCreator onClose={closeModal} pack={editingPackage} />
</Modal>
<table className="bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
>
New Package
</button>
</div>
);
}

View File

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

View File

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

View File

@@ -1,272 +1,375 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions";
import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import { Type, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios";
import clsx from "clsx";
import {capitalize, uniqBy} from "lodash";
import moment from "moment";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-toastify";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions";
import Input from "@/components/Low/Input";
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 {EntityWithRoles} from "@/interfaces/entity";
import { EntityWithRoles } from "@/interfaces/entity";
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
import {mapBy} from "@/utils";
const USER_TYPE_PERMISSIONS: {
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
} = {
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
};
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
permissions: PermissionType[];
onFinish: () => void;
user: User;
users: User[];
entities: EntityWithRoles[];
permissions: PermissionType[];
onFinish: () => void;
}
export default function UserCreator({user, users, entities = [], permissions, onFinish}: Props) {
const [name, setName] = useState<string>();
const [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>();
const [passportID, setPassportID] = useState<string>();
const [studentID, setStudentID] = useState<string>();
const [country, setCountry] = useState(user?.demographicInformation?.country);
const [group, setGroup] = useState<string | null>();
const [password, setPassword] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(
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);
export default function UserCreator({
user,
users,
entities = [],
permissions,
onFinish,
}: Props) {
const [name, setName] = useState<string>();
const [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>();
const [passportID, setPassportID] = useState<string>();
const [studentID, setStudentID] = useState<string>();
const [country, setCountry] = useState(user?.demographicInformation?.country);
const [group, setGroup] = useState<string | null>();
const [password, setPassword] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(
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(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
const createUser = () => {
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
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!");
const createUser = () => {
if (!name || name.trim().length === 0)
return toast.error("Please enter a valid name!");
if (!email || email.trim().length === 0)
return toast.error("Please enter a valid e-mail address!");
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 = {
name,
email,
password,
groupID: group,
entity,
type,
studentID: type === "student" ? studentID : undefined,
expiryDate,
demographicInformation: {
passport_id: type === "student" ? passportID : undefined,
phone,
country,
position,
},
};
const body = {
name,
email,
password,
groupID: group,
entity,
type,
studentID: type === "student" ? studentID : undefined,
expiryDate,
demographicInformation: {
passport_id: type === "student" ? passportID : undefined,
phone,
country,
position,
},
};
axios
.post("/api/make_user", body)
.then(() => {
toast.success("That user has been created!");
onFinish();
axios
.post("/api/make_user", body)
.then(() => {
toast.success("That user has been created!");
onFinish();
setName("");
setEmail("");
setPhone("");
setPassportID("");
setStudentID("");
setCountry(user?.demographicInformation?.country);
setGroup(null);
setEntity((entities || [])[0]?.id || undefined);
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
setIsExpiryDateEnabled(true);
setType("student");
setPosition(undefined);
})
.catch((error) => {
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));
};
setName("");
setEmail("");
setPhone("");
setPassportID("");
setStudentID("");
setCountry(user?.demographicInformation?.country);
setGroup(null);
setEntity((entities || [])[0]?.id || undefined);
setExpiryDate(
user?.subscriptionExpirationDate
? moment(user?.subscriptionExpirationDate).toDate()
: null
);
setIsExpiryDateEnabled(true);
setType("student");
setPosition(undefined);
})
.catch((error) => {
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 (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<div className="grid grid-cols-2 gap-4">
<Input 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" />
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<div className="grid grid-cols-2 gap-4">
<Input
required
label="Name"
value={name}
onChange={setName}
type="text"
name="name"
placeholder="Name"
/>
<Input
label="E-mail"
required
value={email}
onChange={setEmail}
type="email"
name="email"
placeholder="E-mail"
/>
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
<Input
type="password"
name="confirmPassword"
label="Confirm Password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="ConfirmPassword"
required
/>
<Input
type="password"
name="password"
label="Password"
value={password}
onChange={setPassword}
placeholder="Password"
required
/>
<Input
type="password"
name="confirmPassword"
label="Confirm Password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="ConfirmPassword"
required
/>
<div className="flex flex-col gap-4">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<div className="flex flex-col gap-4">
<label className="font-normal text-base text-mti-gray-dim">
Country *
</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" && (
<>
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={setPassportID}
value={passportID}
placeholder="National ID or Passport number"
required
/>
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
</>
)}
{type === "student" && (
<>
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={setPassportID}
value={passportID}
placeholder="National ID or Passport number"
required
/>
<Input
type="text"
name="studentID"
label="Student ID"
onChange={setStudentID}
value={studentID}
placeholder="Student ID"
/>
</>
)}
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{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")}>
<label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select
defaultValue={{
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>
{["corporate", "mastercorporate"].includes(type) && (
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
)}
{["corporate", "mastercorporate"].includes(type) && (
<Input
type="text"
name="department"
label="Department"
onChange={setPosition}
value={position}
placeholder="Department"
/>
)}
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
<Select
options={groups.filter((x) => x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))}
onChange={(e) => setGroup(e?.value || undefined)}
isClearable
/>
</div>
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">
Classroom
</label>
<Select
options={groups
.filter((x) => x.entity?.id === entity)
.map((g) => ({ value: g.id, label: g.name }))}
onChange={(e) => setGroup(e?.value || undefined)}
isClearable
/>
</div>
<div
className={clsx(
"flex flex-col gap-4",
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
)}>
<label className="font-normal text-base text-mti-gray-dim">Type</label>
{user && (
<select
defaultValue="student"
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">
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
</div>
<div
className={clsx(
"flex flex-col gap-4",
!checkAccess(user, [
"developer",
"admin",
"corporate",
"mastercorporate",
]) && "col-span-2"
)}
>
<label className="font-normal text-base text-mti-gray-dim">
Type
</label>
{user && (
<select
defaultValue="student"
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"
>
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
acc.push(x);
return acc;
}, [])}
</select>
)}
</div>
<div className="flex flex-col gap-4">
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user?.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
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",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
{user &&
checkAccess(user, [
"developer",
"admin",
"corporate",
"mastercorporate",
]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">
Expiry Date
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user?.subscriptionExpirationDate}
>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
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"
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user?.subscriptionExpirationDate
? 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}>
Create User
</Button>
</div>
);
<Button
onClick={createUser}
isLoading={isLoading}
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
>
Create User
</Button>
</div>
);
}

View File

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

View File

@@ -13,267 +13,268 @@ import moment from "moment";
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
}
const availableDurations = {
"1_month": { label: "1 Month", number: 1 },
"3_months": { label: "3 Months", number: 3 },
"6_months": { label: "6 Months", number: 6 },
"12_months": { label: "12 Months", number: 12 },
"1_month": { label: "1 Month", number: 1 },
"3_months": { label: "3 Months", number: 3 },
"6_months": { label: "6 Months", number: 6 },
"12_months": { label: "12 Months", number: 12 },
};
export default function RegisterCorporate({
isLoading,
setIsLoading,
mutateUser,
sendEmailVerification,
isLoading,
setIsLoading,
mutateUser,
sendEmailVerification,
}: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [referralAgent, setReferralAgent] = useState<string | undefined>();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [referralAgent, setReferralAgent] = useState<string | undefined>();
const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
const { users } = useUsers();
const { users } = useUsers({ type: "agent" });
const onSuccess = () =>
toast.success(
"An e-mail has been sent, please make sure to check your spam folder!",
);
const onSuccess = () =>
toast.success(
"An e-mail has been sent, please make sure to check your spam folder!"
);
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {
toastId: "send-verify-error",
});
};
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {
toastId: "send-verify-error",
});
};
const register = (e: any) => {
e.preventDefault();
const register = (e: any) => {
e.preventDefault();
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {
toastId: "password-not-match",
});
return;
}
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {
toastId: "password-not-match",
});
return;
}
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "corporate",
profilePicture: "/defaultAvatar.png",
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
corporateInformation: {
monthlyDuration: subscriptionDuration,
referralAgent,
},
})
.then((response) => {
mutateUser(response.data.user).then(() =>
sendEmailVerification(setIsLoading, onSuccess, onError),
);
})
.catch((error) => {
console.log(error.response.data);
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "corporate",
profilePicture: "/defaultAvatar.png",
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
corporateInformation: {
monthlyDuration: subscriptionDuration,
referralAgent,
},
})
.then((response) => {
mutateUser(response.data.user).then(() =>
sendEmailVerification(setIsLoading, onSuccess, onError)
);
})
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
return (
<form
className="flex w-full flex-col items-center gap-4"
onSubmit={register}
>
<div className="flex w-full gap-4">
<Input
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
type="email"
name="email"
onChange={(e) => setEmail(e.toLowerCase())}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
return (
<form
className="flex w-full flex-col items-center gap-4"
onSubmit={register}
>
<div className="flex w-full gap-4">
<Input
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
type="email"
name="email"
onChange={(e) => setEmail(e.toLowerCase())}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
<div className="flex w-full gap-4">
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
</div>
<div className="flex w-full gap-4">
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
</div>
<Divider className="!my-2 w-full" />
<Divider className="!my-2 w-full" />
<div className="flex w-full gap-4">
<Input
type="text"
name="companyName"
onChange={(e) => setCompanyName(e)}
placeholder="Corporate name"
label="Corporate name"
defaultValue={companyName}
required
/>
<Input
type="number"
name="companyUsers"
onChange={(e) => setCompanyUsers(parseInt(e))}
label="Number of users"
defaultValue={companyUsers}
required
/>
</div>
<div className="flex w-full gap-4">
<Input
type="text"
name="companyName"
onChange={(e) => setCompanyName(e)}
placeholder="Corporate name"
label="Corporate name"
defaultValue={companyName}
required
/>
<Input
type="number"
name="companyUsers"
onChange={(e) => setCompanyUsers(parseInt(e))}
label="Number of users"
defaultValue={companyUsers}
required
/>
</div>
<div className="flex w-full gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Referral *
</label>
<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"
options={[
{ value: "", label: "No referral" },
...users
.filter((u) => u.type === "agent")
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
]}
defaultValue={{ value: "", label: "No referral" }}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
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 className="flex w-full gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Referral *
</label>
<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"
options={[
{ value: "", label: "No referral" },
...users.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
]}
defaultValue={{ value: "", label: "No referral" }}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
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 className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Subscription Duration *
</label>
<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"
options={Object.keys(availableDurations).map((value) => ({
value,
label:
availableDurations[value as keyof typeof availableDurations]
.label,
}))}
defaultValue={{
value: "1_month",
label: availableDurations["1_month"].label,
}}
onChange={(value) =>
setSubscriptionDuration(
value
? availableDurations[
value.value as keyof typeof availableDurations
].number
: 1,
)
}
styles={{
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 flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button
className="w-full lg:mt-8"
color="purple"
disabled={
isLoading ||
!email ||
!name ||
!password ||
!confirmPassword ||
password !== confirmPassword ||
!companyName ||
companyUsers <= 0
}
>
Create account
</Button>
</form>
);
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Subscription Duration *
</label>
<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"
options={Object.keys(availableDurations).map((value) => ({
value,
label:
availableDurations[value as keyof typeof availableDurations]
.label,
}))}
defaultValue={{
value: "1_month",
label: availableDurations["1_month"].label,
}}
onChange={(value) =>
setSubscriptionDuration(
value
? availableDurations[
value.value as keyof typeof availableDurations
].number
: 1
)
}
styles={{
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 flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button
className="w-full lg:mt-8"
color="purple"
disabled={
isLoading ||
!email ||
!name ||
!password ||
!confirmPassword ||
password !== confirmPassword ||
!companyName ||
companyUsers <= 0
}
>
Create account
</Button>
</form>
);
}

View File

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

View File

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

View File

@@ -1,250 +1,325 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {ToastContainer} from "react-toastify";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {Radio, RadioGroup} from "@headlessui/react";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { ToastContainer } from "react-toastify";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Radio, RadioGroup } from "@headlessui/react";
import clsx from "clsx";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import Input from "@/components/Low/Input";
import {findAllowedEntities} from "@/utils/permissions";
import {User} from "@/interfaces/user";
import {
findAllowedEntities,
findAllowedEntitiesSomePermissions,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { User } from "@/interfaces/user";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor";
import {mapBy, redirect, serialize} from "@/utils";
import {requestUser} from "@/utils/api";
import {Module} from "@/interfaces";
import {getExam} from "@/utils/exams.be";
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam";
import {useEffect, useState} from "react";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {isAdmin} from "@/utils/users";
import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { Module } from "@/interfaces";
import { getExam } from "@/utils/exams.be";
import {
Exam,
Exercise,
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 {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}) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
export const getServerSideProps = withIronSessionSsr(
async ({ req, res, query }) => {
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 entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
const entityIDs = mapBy(user.entities, "id");
const permissions: Permission = {
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0,
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 entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDs
);
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy");
console.log(entitiesAllowEditPrivacy);
const generatePermissions = groupAllowedEntitiesByPermissions(
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};
if (!id || !examModule) return {props: serialize({user, permissions})};
const {
["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);
if (!exam) return redirect("/generation");
const { id, module: examModule } = query as {
id?: string;
module?: Module;
};
if (!id || !examModule) return { props: serialize({ user, permissions }) };
return {
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
};
}, sessionOptions);
//if (!permissions[module]) return redirect("/generation")
const exam = await getExam(examModule, id);
if (!exam) return redirect("/generation");
return {
props: serialize({
id,
user,
exam,
examModule,
permissions,
entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}),
};
},
sessionOptions
);
export default function Generation({
id,
user,
exam,
examModule,
permissions,
entitiesAllowEditPrivacy,
id,
user,
exam,
examModule,
permissions,
entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}: {
id: string;
user: User;
exam?: Exam;
examModule?: Module;
permissions: Permission;
entitiesAllowEditPrivacy: EntityWithRoles[];
id: string;
user: User;
exam?: Exam;
examModule?: Module;
permissions: Permission;
entitiesAllowEditPrivacy: EntityWithRoles[];
entitiesAllowPublicExams: EntityWithRoles[];
entitiesAllowConfExams: EntityWithRoles[];
}) {
const {title, currentModule, modules, dispatch} = useExamEditorStore();
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined);
const { title, currentModule, modules, dispatch } = useExamEditorStore();
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
undefined
);
const updateRoot = (updates: Partial<ExamEditorStore>) => {
dispatch({type: "UPDATE_ROOT", payload: {updates}});
};
const updateRoot = (updates: Partial<ExamEditorStore>) => {
dispatch({ type: "UPDATE_ROOT", payload: { updates } });
};
useEffect(() => {
if (id && exam && examModule) {
if (examModule === "level" && exam.module === "level") {
setExamLevelParts(exam.parts.length);
}
updateRoot({currentModule: examModule});
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, exam, module]);
useEffect(() => {
if (id && exam && examModule) {
if (examModule === "level" && exam.module === "level") {
setExamLevelParts(exam.parts.length);
}
updateRoot({ currentModule: examModule });
dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, exam, module]);
useEffect(() => {
const fetchAvatars = async () => {
const response = await axios.get("/api/exam/avatars");
updateRoot({speakingAvatars: response.data});
};
useEffect(() => {
const fetchAvatars = async () => {
const response = await axios.get("/api/exam/avatars");
updateRoot({ speakingAvatars: response.data });
};
fetchAvatars();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
fetchAvatars();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// media cleanup on unmount
useEffect(() => {
return () => {
const state = modules;
// media cleanup on unmount
useEffect(() => {
return () => {
const state = modules;
if (state.writing.academic_url) {
URL.revokeObjectURL(state.writing.academic_url);
}
if (state.writing.academic_url) {
URL.revokeObjectURL(state.writing.academic_url);
}
state.listening.sections.forEach((section) => {
const listeningPart = section.state as ListeningPart;
if (listeningPart.audio?.source) {
URL.revokeObjectURL(listeningPart.audio.source);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: {...listeningPart, audio: undefined},
},
});
}
});
state.listening.sections.forEach((section) => {
const listeningPart = section.state as ListeningPart;
if (listeningPart.audio?.source) {
URL.revokeObjectURL(listeningPart.audio.source);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: { ...listeningPart, audio: undefined },
},
});
}
});
if (state.listening.instructionsState.customInstructionsURL.startsWith("blob:")) {
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL);
}
if (
state.listening.instructionsState.customInstructionsURL.startsWith(
"blob:"
)
) {
URL.revokeObjectURL(
state.listening.instructionsState.customInstructionsURL
);
}
state.speaking.sections.forEach((section) => {
const sectionState = section.state as Exercise;
if (sectionState.type === "speaking") {
const speakingExercise = sectionState as SpeakingExercise;
URL.revokeObjectURL(speakingExercise.video_url);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: {...speakingExercise, video_url: undefined},
},
});
}
if (sectionState.type === "interactiveSpeaking") {
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
interactiveSpeaking.prompts.forEach((prompt) => {
URL.revokeObjectURL(prompt.video_url);
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: {
...interactiveSpeaking,
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})),
},
},
});
}
});
dispatch({type: "FULL_RESET"});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
state.speaking.sections.forEach((section) => {
const sectionState = section.state as Exercise;
if (sectionState.type === "speaking") {
const speakingExercise = sectionState as SpeakingExercise;
URL.revokeObjectURL(speakingExercise.video_url);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: { ...speakingExercise, video_url: undefined },
},
});
}
if (sectionState.type === "interactiveSpeaking") {
const interactiveSpeaking =
sectionState as InteractiveSpeakingExercise;
interactiveSpeaking.prompts.forEach((prompt) => {
URL.revokeObjectURL(prompt.video_url);
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: {
...interactiveSpeaking,
prompts: interactiveSpeaking.prompts.map((p) => ({
...p,
video_url: undefined,
})),
},
},
});
}
});
dispatch({ type: "FULL_RESET" });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<Head>
<title>Exam Generation | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<>
<h1 className="text-2xl font-semibold">Exam Editor</h1>
<div className="flex flex-col gap-3">
<Input
type="text"
placeholder="Insert a title here"
name="title"
label="Title"
onChange={(title) => updateRoot({title})}
roundness="xl"
value={title}
defaultValue={title}
required
/>
<label className="font-normal text-base text-mti-gray-dim">Module</label>
<RadioGroup
value={currentModule}
onChange={(currentModule) => updateRoot({currentModule})}
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...MODULE_ARRAY]
.filter((m) => permissions[m])
.map((x) => (
<Radio value={x} key={x}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
x === "reading" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-reading/70 border-ielts-reading text-white"),
x === "listening" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white"),
x === "writing" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-writing/70 border-ielts-writing text-white"),
x === "speaking" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
x === "level" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white"),
)}>
{capitalize(x)}
</span>
)}
</Radio>
))}
</RadioGroup>
</div>
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
</>
)}
</>
);
return (
<>
<Head>
<title>Exam Generation | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<>
<h1 className="text-2xl font-semibold">Exam Editor</h1>
<div className="flex flex-col gap-3">
<Input
type="text"
placeholder="Insert a title here"
name="title"
label="Title"
onChange={(title) => updateRoot({ title })}
roundness="xl"
value={title}
defaultValue={title}
required
/>
<label className="font-normal text-base text-mti-gray-dim">
Module
</label>
<RadioGroup
value={currentModule}
onChange={(currentModule) => updateRoot({ currentModule })}
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
>
{[...MODULE_ARRAY].reduce((acc, x) => {
if (permissions[x])
acc.push(
<Radio value={x} key={x}>
{({ checked }) => (
<span
className={clsx(
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
x === "reading" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-reading/70 border-ielts-reading text-white"),
x === "listening" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white"),
x === "writing" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-writing/70 border-ielts-writing text-white"),
x === "speaking" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
x === "level" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white")
)}
>
{capitalize(x)}
</span>
)}
</Radio>
);
return acc;
}, [] as JSX.Element[])}
</RadioGroup>
</div>
<ExamEditor
levelParts={examLevelParts}
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
entitiesAllowConfExams={entitiesAllowConfExams}
entitiesAllowPublicExams={entitiesAllowPublicExams}
/>
</>
)}
</>
);
}

View File

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

View File

@@ -203,6 +203,32 @@ const Training: React.FC<{
</Head>
<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 ? (
<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>
) : (
<>
<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 && (
<div className="flex flex-grow justify-center items-center">
<span className="font-semibold ml-1">
No training content to display...
</span>
</div>
)}
{!areRecordsLoading &&
groupedByTrainingContent &&

View File

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

View File

@@ -14,7 +14,7 @@ export type RootActions =
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } |
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
{ type: 'FINALIZE_MODULE_SOLUTIONS' } |
{ type: 'UPDATE_EXAMS'}
{ type: 'UPDATE_EXAMS' }
export type Action = RootActions | SessionActions;
@@ -130,7 +130,7 @@ export const rootReducer = (
if (state.flags.reviewAll) {
const notLastModule = state.moduleIndex < state.selectedModules.length - 1;
const moduleIndex = notLastModule ? state.moduleIndex + 1 : -1;
if (notLastModule) {
return {
questionIndex: 0,
@@ -152,7 +152,7 @@ export const rootReducer = (
moduleIndex: -1
}
}
}
case 'UPDATE_EXAMS': {
const exams = state.exams.map((e) => updateExamWithUserSolutions(e, state.userSolutions));

View File

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