Merged in bugfixes-generationdesignchanges (pull request #155)

bugsfixed and design changes for generation 13'' screen

Approved-by: Tiago Ribeiro
This commit is contained in:
Francisco Lima
2025-02-24 13:38:54 +00:00
committed by Tiago Ribeiro
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)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''} contentWrapperClassName={level ? `border border-ielts-listening` : ''}
> >
<div className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4"> <div className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2"> <div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label> <label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input <Input

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
import clsx from "clsx"; import clsx from "clsx";
import SectionRenderer from "./SectionRenderer"; import SectionRenderer from "./SectionRenderer";
import Checkbox from "../Low/Checkbox";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam"; import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { ModuleState, SectionState } from "@/stores/examEditor/types"; import { ModuleState, SectionState } from "@/stores/examEditor/types";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
@@ -21,13 +20,36 @@ import Button from "../Low/Button";
import ResetModule from "./Standalone/ResetModule"; import ResetModule from "./Standalone/ResetModule";
import ListeningInstructions from "./Standalone/ListeningInstructions"; import ListeningInstructions from "./Standalone/ListeningInstructions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Option from "../../interfaces/option";
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; const DIFFICULTIES: Option[] = [
{ value: "A1", label: "A1" },
{ value: "A2", label: "A2" },
{ value: "B1", label: "B1" },
{ value: "B2", label: "B2" },
{ value: "C1", label: "C1" },
{ value: "C2", label: "C2" },
];
const ModuleSettings: Record<Module, React.ComponentType> = {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings,
};
const ExamEditor: React.FC<{ const ExamEditor: React.FC<{
levelParts?: number; levelParts?: number;
entitiesAllowEditPrivacy: EntityWithRoles[]; entitiesAllowEditPrivacy: EntityWithRoles[];
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => { entitiesAllowConfExams: EntityWithRoles[];
entitiesAllowPublicExams: EntityWithRoles[];
}> = ({
levelParts = 0,
entitiesAllowEditPrivacy = [],
entitiesAllowConfExams = [],
entitiesAllowPublicExams = [],
}) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const { const {
sections, sections,
@@ -111,7 +133,10 @@ const ExamEditor: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]); }, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId); const sectionIds = useMemo(
() => sections.map((section) => section.sectionId),
[sections]
);
const updateModule = useCallback( const updateModule = useCallback(
(updates: Partial<ModuleState>) => { (updates: Partial<ModuleState>) => {
@@ -120,29 +145,42 @@ const ExamEditor: React.FC<{
[dispatch] [dispatch]
); );
const toggleSection = (sectionId: number) => { const toggleSection = useCallback(
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { (sectionId: number) => {
toast.error("Include at least one section!"); if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
return; toast.error("Include at least one section!");
return;
}
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
},
[dispatch, expandedSections, sectionIds]
);
const Settings = useMemo(
() => ModuleSettings[currentModule],
[currentModule]
);
const showImport = useMemo(
() =>
importModule && ["reading", "listening", "level"].includes(currentModule),
[importModule, currentModule]
);
const accessTypeOptions = useMemo(() => {
let options: Option[] = [{ value: "private", label: "Private" }];
if (entitiesAllowConfExams.length > 0) {
options.push({ value: "confidential", label: "Confidential" });
} }
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } }); if (entitiesAllowPublicExams.length > 0) {
}; options.push({ value: "public", label: "Public" });
}
return options;
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
const ModuleSettings: Record<Module, React.ComponentType> = { const updateLevelParts = useCallback((parts: number) => {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings,
};
const Settings = ModuleSettings[currentModule];
const showImport =
importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts); setNumberOfLevelParts(parts);
}; }, []);
return ( return (
<> <>
@@ -161,9 +199,14 @@ const ExamEditor: React.FC<{
setNumberOfLevelParts={setNumberOfLevelParts} setNumberOfLevelParts={setNumberOfLevelParts}
/> />
)} )}
<div className="flex gap-4 w-full items-center -xl:flex-col"> <div
<div className="flex flex-row gap-3 w-full"> className={clsx(
<div className="flex flex-col gap-3"> "flex gap-4 w-full",
sectionLabels.length > 3 ? "-2xl:flex-col" : "-xl:flex-col"
)}
>
<div className="flex flex-row gap-3">
<div className="flex flex-col gap-3 ">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
Timer Timer
</label> </label>
@@ -176,19 +219,16 @@ const ExamEditor: React.FC<{
}) })
} }
value={minTimer} value={minTimer}
className="max-w-[300px]" className="max-w-[125px] min-w-[100px] w-min"
/> />
</div> </div>
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 ">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
Difficulty Difficulty
</label> </label>
<Select <Select
isMulti={true} isMulti={true}
options={DIFFICULTIES.map((x) => ({ options={DIFFICULTIES}
value: x,
label: capitalize(x),
}))}
onChange={(values) => { onChange={(values) => {
const selectedDifficulties = values const selectedDifficulties = values
? values.map((v) => v.value as Difficulty) ? values.map((v) => v.value as Difficulty)
@@ -214,12 +254,12 @@ const ExamEditor: React.FC<{
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
{sectionLabels[0].label.split(" ")[0]} {sectionLabels[0].label.split(" ")[0]}
</label> </label>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-3">
{sectionLabels.map(({ id, label }) => ( {sectionLabels.map(({ id, label }) => (
<span <span
key={id} key={id}
className={clsx( className={clsx(
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
sectionIds.includes(id) sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white` ? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
@@ -246,22 +286,24 @@ const ExamEditor: React.FC<{
/> />
</div> </div>
)} )}
</div> <div className="max-w-[200px] w-full">
<div className="flex flex-row gap-3 w-64"> <Select
<Select label="Access Type"
label="Access Type" disabled={
options={ACCESSTYPE.map((item) => ({ accessTypeOptions.length === 0 ||
value: item, entitiesAllowEditPrivacy.length === 0
label: capitalize(item),
}))}
onChange={(value) => {
if (value?.value) {
updateModule({ access: value.value! as AccessType });
} }
}} options={accessTypeOptions}
value={{ value: access, label: capitalize(access) }} onChange={(value) => {
/> if (value?.value) {
updateModule({ access: value.value! as AccessType });
}
}}
value={{ value: access, label: capitalize(access) }}
/>
</div>
</div> </div>
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
@@ -286,7 +328,7 @@ const ExamEditor: React.FC<{
Reset Module Reset Module
</Button> </Button>
</div> </div>
<div className="flex flex-row gap-8 -2xl:flex-col"> <div className="flex flex-row gap-8 -xl:flex-col">
<Settings /> <Settings />
<div className="flex-grow max-w-[66%] -2xl:max-w-full"> <div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer /> <SectionRenderer />

View File

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

View File

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

View File

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

View File

@@ -121,12 +121,12 @@ export default function Sidebar({
entities, entities,
"view_statistics" "view_statistics"
); );
const entitiesAllowPaymentRecord = useAllowedEntities( const entitiesAllowPaymentRecord = useAllowedEntities(
user, user,
entities, entities,
"view_payment_record" "view_payment_record"
); );
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions( const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
user, user,
entities, entities,
@@ -148,7 +148,7 @@ export default function Sidebar({
viewTickets: true, viewTickets: true,
viewClassrooms: true, viewClassrooms: true,
viewSettings: true, viewSettings: true,
viewPaymentRecord: true, viewPaymentRecords: true,
viewGeneration: true, viewGeneration: true,
viewApprovalWorkflows: true, viewApprovalWorkflows: true,
}; };
@@ -160,7 +160,7 @@ export default function Sidebar({
viewTickets: false, viewTickets: false,
viewClassrooms: false, viewClassrooms: false,
viewSettings: false, viewSettings: false,
viewPaymentRecord: false, viewPaymentRecords: false,
viewGeneration: false, viewGeneration: false,
viewApprovalWorkflows: false, viewApprovalWorkflows: false,
}; };
@@ -235,7 +235,7 @@ export default function Sidebar({
) && ) &&
entitiesAllowPaymentRecord.length > 0 entitiesAllowPaymentRecord.length > 0
) { ) {
sidebarPermissions["viewPaymentRecord"] = true; sidebarPermissions["viewPaymentRecords"] = true;
} }
return sidebarPermissions; return sidebarPermissions;
}, [ }, [
@@ -378,7 +378,6 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav <Nav
@@ -427,6 +426,16 @@ export default function Sidebar({
isMinimized isMinimized
/> />
)} )}
{sidebarPermissions["viewPaymentRecords"] && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized
/>
)}
{sidebarPermissions["viewSettings"] && ( {sidebarPermissions["viewSettings"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
@@ -459,7 +468,7 @@ export default function Sidebar({
)} )}
</div> </div>
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8"> <div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8 ">
<div <div
role="button" role="button"
tabIndex={1} tabIndex={1}
@@ -483,7 +492,7 @@ export default function Sidebar({
tabIndex={1} tabIndex={1}
onClick={focusMode ? () => {} : logout} onClick={focusMode ? () => {} : logout}
className={clsx( className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out -xl:px-4",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8" isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)} )}
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ export type RootActions =
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } | { type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } |
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } | { type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
{ type: 'FINALIZE_MODULE_SOLUTIONS' } | { type: 'FINALIZE_MODULE_SOLUTIONS' } |
{ type: 'UPDATE_EXAMS'} { type: 'UPDATE_EXAMS' }
export type Action = RootActions | SessionActions; export type Action = RootActions | SessionActions;

View File

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