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(
(sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
toast.error("Include at least one section!"); toast.error("Include at least one section!");
return; return;
} }
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } }); dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
}; },
[dispatch, expandedSections, sectionIds]
);
const ModuleSettings: Record<Module, React.ComponentType> = { const Settings = useMemo(
reading: ReadingSettings, () => ModuleSettings[currentModule],
writing: WritingSettings, [currentModule]
speaking: SpeakingSettings, );
listening: ListeningSettings,
level: LevelSettings,
};
const Settings = ModuleSettings[currentModule]; const showImport = useMemo(
const showImport = () =>
importModule && ["reading", "listening", "level"].includes(currentModule); importModule && ["reading", "listening", "level"].includes(currentModule),
[importModule, currentModule]
);
const updateLevelParts = (parts: number) => { const accessTypeOptions = useMemo(() => {
let options: Option[] = [{ value: "private", label: "Private" }];
if (entitiesAllowConfExams.length > 0) {
options.push({ value: "confidential", label: "Confidential" });
}
if (entitiesAllowPublicExams.length > 0) {
options.push({ value: "public", label: "Public" });
}
return options;
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
const updateLevelParts = useCallback((parts: number) => {
setNumberOfLevelParts(parts); setNumberOfLevelParts(parts);
}; }, []);
return ( return (
<> <>
@@ -161,9 +199,14 @@ const ExamEditor: React.FC<{
setNumberOfLevelParts={setNumberOfLevelParts} setNumberOfLevelParts={setNumberOfLevelParts}
/> />
)} )}
<div className="flex gap-4 w-full items-center -xl:flex-col"> <div
<div className="flex flex-row gap-3 w-full"> className={clsx(
<div className="flex flex-col gap-3"> "flex gap-4 w-full",
sectionLabels.length > 3 ? "-2xl:flex-col" : "-xl:flex-col"
)}
>
<div className="flex flex-row gap-3">
<div className="flex flex-col gap-3 ">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
Timer Timer
</label> </label>
@@ -176,19 +219,16 @@ const ExamEditor: React.FC<{
}) })
} }
value={minTimer} value={minTimer}
className="max-w-[300px]" className="max-w-[125px] min-w-[100px] w-min"
/> />
</div> </div>
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 ">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
Difficulty Difficulty
</label> </label>
<Select <Select
isMulti={true} isMulti={true}
options={DIFFICULTIES.map((x) => ({ options={DIFFICULTIES}
value: x,
label: capitalize(x),
}))}
onChange={(values) => { onChange={(values) => {
const selectedDifficulties = values const selectedDifficulties = values
? values.map((v) => v.value as Difficulty) ? values.map((v) => v.value as Difficulty)
@@ -214,12 +254,12 @@ const ExamEditor: React.FC<{
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
{sectionLabels[0].label.split(" ")[0]} {sectionLabels[0].label.split(" ")[0]}
</label> </label>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-3">
{sectionLabels.map(({ id, label }) => ( {sectionLabels.map(({ id, label }) => (
<span <span
key={id} key={id}
className={clsx( className={clsx(
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
sectionIds.includes(id) sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white` ? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
@@ -246,14 +286,14 @@ const ExamEditor: React.FC<{
/> />
</div> </div>
)} )}
</div> <div className="max-w-[200px] w-full">
<div className="flex flex-row gap-3 w-64">
<Select <Select
label="Access Type" label="Access Type"
options={ACCESSTYPE.map((item) => ({ disabled={
value: item, accessTypeOptions.length === 0 ||
label: capitalize(item), entitiesAllowEditPrivacy.length === 0
}))} }
options={accessTypeOptions}
onChange={(value) => { onChange={(value) => {
if (value?.value) { if (value?.value) {
updateModule({ access: value.value! as AccessType }); updateModule({ access: value.value! as AccessType });
@@ -262,6 +302,8 @@ const ExamEditor: React.FC<{
value={{ value: access, label: capitalize(access) }} value={{ value: access, label: capitalize(access) }}
/> />
</div> </div>
</div>
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
@@ -286,7 +328,7 @@ const ExamEditor: React.FC<{
Reset Module Reset Module
</Button> </Button>
</div> </div>
<div className="flex flex-row gap-8 -2xl:flex-col"> <div className="flex flex-row gap-8 -xl:flex-col">
<Settings /> <Settings />
<div className="flex-grow max-w-[66%] -2xl:max-w-full"> <div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer /> <SectionRenderer />

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,31 +1,23 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions"; import { Type, User } from "@/interfaces/user";
import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, uniqBy} from "lodash";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id"; import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import { PermissionType } from "@/interfaces/permissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import CountrySelect from "@/components/Low/CountrySelect"; import CountrySelect from "@/components/Low/CountrySelect";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {getUserName} from "@/utils/users";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {EntityWithRoles} from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import useEntitiesGroups from "@/hooks/useEntitiesGroups"; import useEntitiesGroups from "@/hooks/useEntitiesGroups";
import {mapBy} from "@/utils";
const USER_TYPE_PERMISSIONS: { const USER_TYPE_PERMISSIONS: {
[key in Type]: {perm: PermissionType | undefined; list: Type[]}; [key in Type]: { perm: PermissionType | undefined; list: Type[] };
} = { } = {
student: { student: {
perm: "createCodeStudent", perm: "createCodeStudent",
@@ -49,11 +41,26 @@ const USER_TYPE_PERMISSIONS: {
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
}, },
developer: { developer: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
}, },
}; };
@@ -65,7 +72,13 @@ interface Props {
onFinish: () => void; onFinish: () => void;
} }
export default function UserCreator({user, users, entities = [], permissions, onFinish}: Props) { export default function UserCreator({
user,
users,
entities = [],
permissions,
onFinish,
}: Props) {
const [name, setName] = useState<string>(); const [name, setName] = useState<string>();
const [email, setEmail] = useState<string>(); const [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>(); const [phone, setPhone] = useState<string>();
@@ -76,7 +89,9 @@ export default function UserCreator({user, users, entities = [], permissions, on
const [password, setPassword] = useState<string>(); const [password, setPassword] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>(); const [confirmPassword, setConfirmPassword] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate
? moment(user?.subscriptionExpirationDate).toDate()
: null
); );
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -84,18 +99,23 @@ export default function UserCreator({user, users, entities = [], permissions, on
const [position, setPosition] = useState<string>(); const [position, setPosition] = useState<string>();
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined); const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const {groups} = useEntitiesGroups(); const { groups } = useEntitiesGroups();
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const createUser = () => { const createUser = () => {
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!"); if (!name || name.trim().length === 0)
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!"); return toast.error("Please enter a valid name!");
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!"); if (!email || email.trim().length === 0)
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!"); return toast.error("Please enter a valid e-mail address!");
if (password !== confirmPassword) return toast.error("The passwords do not match!"); if (users.map((x) => x.email).includes(email.trim()))
return toast.error("That e-mail is already in use!");
if (!password || password.trim().length < 6)
return toast.error("Please enter a valid password!");
if (password !== confirmPassword)
return toast.error("The passwords do not match!");
setIsLoading(true); setIsLoading(true);
@@ -130,7 +150,11 @@ export default function UserCreator({user, users, entities = [], permissions, on
setCountry(user?.demographicInformation?.country); setCountry(user?.demographicInformation?.country);
setGroup(null); setGroup(null);
setEntity((entities || [])[0]?.id || undefined); setEntity((entities || [])[0]?.id || undefined);
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null); setExpiryDate(
user?.subscriptionExpirationDate
? moment(user?.subscriptionExpirationDate).toDate()
: null
);
setIsExpiryDateEnabled(true); setIsExpiryDateEnabled(true);
setType("student"); setType("student");
setPosition(undefined); setPosition(undefined);
@@ -146,10 +170,34 @@ export default function UserCreator({user, users, entities = [], permissions, on
return ( return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" /> <Input
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" /> required
label="Name"
value={name}
onChange={setName}
type="text"
name="name"
placeholder="Name"
/>
<Input
label="E-mail"
required
value={email}
onChange={setEmail}
type="email"
name="email"
placeholder="E-mail"
/>
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required /> <Input
type="password"
name="password"
label="Password"
value={password}
onChange={setPassword}
placeholder="Password"
required
/>
<Input <Input
type="password" type="password"
name="confirmPassword" name="confirmPassword"
@@ -161,11 +209,21 @@ export default function UserCreator({user, users, entities = [], permissions, on
/> />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<label className="font-normal text-base text-mti-gray-dim">Country *</label> <label className="font-normal text-base text-mti-gray-dim">
Country *
</label>
<CountrySelect value={country} onChange={setCountry} /> <CountrySelect value={country} onChange={setCountry} />
</div> </div>
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required /> <Input
type="tel"
name="phone"
label="Phone number"
value={phone}
onChange={setPhone}
placeholder="Phone number"
required
/>
{type === "student" && ( {type === "student" && (
<> <>
@@ -178,28 +236,51 @@ export default function UserCreator({user, users, entities = [], permissions, on
placeholder="National ID or Passport number" placeholder="National ID or Passport number"
required required
/> />
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" /> <Input
type="text"
name="studentID"
label="Student ID"
onChange={setStudentID}
value={studentID}
placeholder="Student ID"
/>
</> </>
)} )}
<div className={clsx("flex flex-col gap-4")}> <div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label> <label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select <Select
defaultValue={{value: (entities || [])[0]?.id, label: (entities || [])[0]?.label}} defaultValue={{
options={entities.map((e) => ({value: e.id, label: e.label}))} value: (entities || [])[0]?.id,
label: (entities || [])[0]?.label,
}}
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)} onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])} isClearable={checkAccess(user, ["admin", "developer"])}
/> />
</div> </div>
{["corporate", "mastercorporate"].includes(type) && ( {["corporate", "mastercorporate"].includes(type) && (
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" /> <Input
type="text"
name="department"
label="Department"
onChange={setPosition}
value={position}
placeholder="Department"
/>
)} )}
<div className={clsx("flex flex-col gap-4")}> <div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Classroom</label> <label className="font-normal text-base text-mti-gray-dim">
Classroom
</label>
<Select <Select
options={groups.filter((x) => x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))} options={groups
.filter((x) => x.entity?.id === entity)
.map((g) => ({ value: g.id, label: g.name }))}
onChange={(e) => setGroup(e?.value || undefined)} onChange={(e) => setGroup(e?.value || undefined)}
isClearable isClearable
/> />
@@ -208,38 +289,52 @@ export default function UserCreator({user, users, entities = [], permissions, on
<div <div
className={clsx( className={clsx(
"flex flex-col gap-4", "flex flex-col gap-4",
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2", !checkAccess(user, [
)}> "developer",
<label className="font-normal text-base text-mti-gray-dim">Type</label> "admin",
"corporate",
"mastercorporate",
]) && "col-span-2"
)}
>
<label className="font-normal text-base text-mti-gray-dim">
Type
</label>
{user && ( {user && (
<select <select
defaultValue="student" defaultValue="student"
value={type} value={type}
onChange={(e) => setType(e.target.value as Type)} onChange={(e) => setType(e.target.value as Type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
{Object.keys(USER_TYPE_LABELS) >
.filter((x) => { {Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type]; const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm); if (checkAccess(user, getTypesOfUser(list), permissions, perm))
}) acc.push(x);
.map((type) => ( return acc;
<option key={type} value={type}> }, [])}
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select> </select>
)} )}
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( {user &&
checkAccess(user, [
"developer",
"admin",
"corporate",
"mastercorporate",
]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">
Expiry Date
</label>
<Checkbox <Checkbox
isChecked={isExpiryDateEnabled} isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled} onChange={setIsExpiryDateEnabled}
disabled={!!user?.subscriptionExpirationDate}> disabled={!!user?.subscriptionExpirationDate}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -248,11 +343,15 @@ export default function UserCreator({user, users, entities = [], permissions, on
className={clsx( className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
filterDate={(date) => filterDate={(date) =>
moment(date).isAfter(new Date()) && moment(date).isAfter(new Date()) &&
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) (user?.subscriptionExpirationDate
? moment(date).isBefore(
user?.subscriptionExpirationDate
)
: true)
} }
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
@@ -264,7 +363,11 @@ export default function UserCreator({user, users, entities = [], permissions, on
</div> </div>
</div> </div>
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}> <Button
onClick={createUser}
isLoading={isLoading}
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
>
Create User Create User
</Button> </Button>
</div> </div>

View File

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

View File

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

View File

@@ -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,56 +1,93 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {ToastContainer} from "react-toastify"; import { ToastContainer } from "react-toastify";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {Radio, RadioGroup} from "@headlessui/react"; import { Radio, RadioGroup } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {findAllowedEntities} from "@/utils/permissions"; import {
import {User} from "@/interfaces/user"; findAllowedEntities,
findAllowedEntitiesSomePermissions,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { User } from "@/interfaces/user";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor"; import ExamEditor from "@/components/ExamEditor";
import {mapBy, redirect, serialize} from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import {requestUser} from "@/utils/api"; import { requestUser } from "@/utils/api";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {getExam} from "@/utils/exams.be"; import { getExam } from "@/utils/exams.be";
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam"; import {
import {useEffect, useState} from "react"; Exam,
import {getEntitiesWithRoles} from "@/utils/entities.be"; Exercise,
import {isAdmin} from "@/utils/users"; InteractiveSpeakingExercise,
ListeningPart,
SpeakingExercise,
} from "@/interfaces/exam";
import { useEffect, useState } from "react";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users";
import axios from "axios"; import axios from "axios";
import {EntityWithRoles} from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
type Permission = {[key in Module]: boolean}; type Permission = { [key in Module]: boolean };
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { export const getServerSideProps = withIronSessionSsr(
async ({ req, res, query }) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return redirect("/login"); if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/"); if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, "id"); const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDs
);
const generatePermissions = groupAllowedEntitiesByPermissions(
user,
entities,
[
"generate_reading",
"generate_listening",
"generate_writing",
"generate_speaking",
"generate_level",
]
);
const permissions: Permission = { const permissions: Permission = {
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0, reading: generatePermissions["generate_reading"].length > 0,
listening: findAllowedEntities(user, entities, `generate_listening`).length > 0, listening: generatePermissions["generate_listening"].length > 0,
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0, writing: generatePermissions["generate_writing"].length > 0,
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0, speaking: generatePermissions["generate_speaking"].length > 0,
level: findAllowedEntities(user, entities, `generate_level`).length > 0, level: generatePermissions["generate_level"].length > 0,
}; };
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy"); const {
console.log(entitiesAllowEditPrivacy); ["update_exam_privacy"]: entitiesAllowEditPrivacy,
["create_confidential_exams"]: entitiesAllowConfExams,
["create_public_exams"]: entitiesAllowPublicExams,
} = groupAllowedEntitiesByPermissions(user, entities, [
"update_exam_privacy",
"create_confidential_exams",
"create_public_exams",
]);
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/"); if (Object.keys(permissions).every((p) => !permissions[p as Module]))
return redirect("/");
const {id, module: examModule} = query as {id?: string; module?: Module}; const { id, module: examModule } = query as {
if (!id || !examModule) return {props: serialize({user, permissions})}; id?: string;
module?: Module;
};
if (!id || !examModule) return { props: serialize({ user, permissions }) };
//if (!permissions[module]) return redirect("/generation") //if (!permissions[module]) return redirect("/generation")
@@ -58,9 +95,20 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) =
if (!exam) return redirect("/generation"); if (!exam) return redirect("/generation");
return { return {
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}), props: serialize({
id,
user,
exam,
examModule,
permissions,
entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}),
}; };
}, sessionOptions); },
sessionOptions
);
export default function Generation({ export default function Generation({
id, id,
@@ -69,6 +117,8 @@ export default function Generation({
examModule, examModule,
permissions, permissions,
entitiesAllowEditPrivacy, entitiesAllowEditPrivacy,
entitiesAllowConfExams,
entitiesAllowPublicExams,
}: { }: {
id: string; id: string;
user: User; user: User;
@@ -76,12 +126,16 @@ export default function Generation({
examModule?: Module; examModule?: Module;
permissions: Permission; permissions: Permission;
entitiesAllowEditPrivacy: EntityWithRoles[]; entitiesAllowEditPrivacy: EntityWithRoles[];
entitiesAllowPublicExams: EntityWithRoles[];
entitiesAllowConfExams: EntityWithRoles[];
}) { }) {
const {title, currentModule, modules, dispatch} = useExamEditorStore(); const { title, currentModule, modules, dispatch } = useExamEditorStore();
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined); const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
undefined
);
const updateRoot = (updates: Partial<ExamEditorStore>) => { const updateRoot = (updates: Partial<ExamEditorStore>) => {
dispatch({type: "UPDATE_ROOT", payload: {updates}}); dispatch({ type: "UPDATE_ROOT", payload: { updates } });
}; };
useEffect(() => { useEffect(() => {
@@ -89,8 +143,8 @@ export default function Generation({
if (examModule === "level" && exam.module === "level") { if (examModule === "level" && exam.module === "level") {
setExamLevelParts(exam.parts.length); setExamLevelParts(exam.parts.length);
} }
updateRoot({currentModule: examModule}); updateRoot({ currentModule: examModule });
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}}); dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, exam, module]); }, [id, exam, module]);
@@ -98,7 +152,7 @@ export default function Generation({
useEffect(() => { useEffect(() => {
const fetchAvatars = async () => { const fetchAvatars = async () => {
const response = await axios.get("/api/exam/avatars"); const response = await axios.get("/api/exam/avatars");
updateRoot({speakingAvatars: response.data}); updateRoot({ speakingAvatars: response.data });
}; };
fetchAvatars(); fetchAvatars();
@@ -124,14 +178,20 @@ export default function Generation({
sectionId: section.sectionId, sectionId: section.sectionId,
module: "listening", module: "listening",
field: "state", field: "state",
value: {...listeningPart, audio: undefined}, value: { ...listeningPart, audio: undefined },
}, },
}); });
} }
}); });
if (state.listening.instructionsState.customInstructionsURL.startsWith("blob:")) { if (
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL); state.listening.instructionsState.customInstructionsURL.startsWith(
"blob:"
)
) {
URL.revokeObjectURL(
state.listening.instructionsState.customInstructionsURL
);
} }
state.speaking.sections.forEach((section) => { state.speaking.sections.forEach((section) => {
@@ -145,12 +205,13 @@ export default function Generation({
sectionId: section.sectionId, sectionId: section.sectionId,
module: "listening", module: "listening",
field: "state", field: "state",
value: {...speakingExercise, video_url: undefined}, value: { ...speakingExercise, video_url: undefined },
}, },
}); });
} }
if (sectionState.type === "interactiveSpeaking") { if (sectionState.type === "interactiveSpeaking") {
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise; const interactiveSpeaking =
sectionState as InteractiveSpeakingExercise;
interactiveSpeaking.prompts.forEach((prompt) => { interactiveSpeaking.prompts.forEach((prompt) => {
URL.revokeObjectURL(prompt.video_url); URL.revokeObjectURL(prompt.video_url);
}); });
@@ -162,13 +223,16 @@ export default function Generation({
field: "state", field: "state",
value: { value: {
...interactiveSpeaking, ...interactiveSpeaking,
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})), prompts: interactiveSpeaking.prompts.map((p) => ({
...p,
video_url: undefined,
})),
}, },
}, },
}); });
} }
}); });
dispatch({type: "FULL_RESET"}); dispatch({ type: "FULL_RESET" });
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -194,22 +258,25 @@ export default function Generation({
placeholder="Insert a title here" placeholder="Insert a title here"
name="title" name="title"
label="Title" label="Title"
onChange={(title) => updateRoot({title})} onChange={(title) => updateRoot({ title })}
roundness="xl" roundness="xl"
value={title} value={title}
defaultValue={title} defaultValue={title}
required required
/> />
<label className="font-normal text-base text-mti-gray-dim">Module</label> <label className="font-normal text-base text-mti-gray-dim">
Module
</label>
<RadioGroup <RadioGroup
value={currentModule} value={currentModule}
onChange={(currentModule) => updateRoot({currentModule})} onChange={(currentModule) => updateRoot({ currentModule })}
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"> className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
{[...MODULE_ARRAY] >
.filter((m) => permissions[m]) {[...MODULE_ARRAY].reduce((acc, x) => {
.map((x) => ( if (permissions[x])
acc.push(
<Radio value={x} key={x}> <Radio value={x} key={x}>
{({checked}) => ( {({ checked }) => (
<span <span
className={clsx( className={clsx(
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
@@ -233,16 +300,24 @@ export default function Generation({
x === "level" && x === "level" &&
(!checked (!checked
? "bg-white border-mti-gray-platinum" ? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white"), : "bg-ielts-level/70 border-ielts-level text-white")
)}> )}
>
{capitalize(x)} {capitalize(x)}
</span> </span>
)} )}
</Radio> </Radio>
))} );
return acc;
}, [] as JSX.Element[])}
</RadioGroup> </RadioGroup>
</div> </div>
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} /> <ExamEditor
levelParts={examLevelParts}
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
entitiesAllowConfExams={entitiesAllowConfExams}
entitiesAllowPublicExams={entitiesAllowPublicExams}
/>
</> </>
)} )}
</> </>

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

View File

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