Merge branches 'main' and 'develop' of bitbucket.org:ecropdev/ielts-ui into develop

This commit is contained in:
Tiago Ribeiro
2025-02-06 11:52:16 +00:00
127 changed files with 16655 additions and 8880 deletions

2
.gitignore vendored
View File

@@ -41,3 +41,5 @@ next-env.d.ts
.yarn/* .yarn/*
.history* .history*
__ENV.js __ENV.js
settings.json

View File

@@ -0,0 +1,32 @@
import Image from "next/image";
import React from "react";
import { FaRegUser } from "react-icons/fa";
interface Props {
prefix: string;
name: string;
profileImage: string;
}
export default function RequestedBy({ prefix, name, profileImage }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<FaRegUser className="text-mti-purple-dark size-5"/>
</div>
<div>
<p className="text-sm font-medium text-gray-800">Requested by</p>
<div className="flex items-center space-x-2">
<p className="text-xs font-medium text-gray-800">{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="w-6 h-6 rounded-full border-[1px] border-gray-400 border-opacity-50"
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { PiCalendarDots } from "react-icons/pi";
interface Props {
date: number;
}
export default function StartedOn({ date }: Props) {
const formattedDate = new Date(date);
const yearMonthDay = formattedDate.toISOString().split("T")[0];
const fullDateTime = formattedDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<PiCalendarDots className="text-mti-purple-dark size-7" />
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Started on</p>
<div className="flex items-center">
<p
className="text-xs font-medium text-gray-800"
title={fullDateTime}
>
{yearMonthDay}
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel } from "@/interfaces/approval.workflow";
import React from "react";
import { RiProgress5Line } from "react-icons/ri";
interface Props {
status: ApprovalWorkflowStatus;
}
export default function Status({ status }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<RiProgress5Line className="text-mti-purple-dark size-7"/>
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Status</p>
<div className="flex items-center">
<p className="text-xs font-medium text-gray-800">{ApprovalWorkflowStatusLabel[status]}</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { MdTipsAndUpdates } from "react-icons/md";
interface Props {
text: string;
}
export default function Tip({ text }: Props) {
return (
<div className="flex flex-row gap-3 text-gray-500 font-medium">
<MdTipsAndUpdates size={25} />
<p>{text}</p>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import Image from "next/image";
interface Props {
prefix: string;
name: string;
profileImage: string;
textSize?: string;
}
export default function UserWithProfilePic({ prefix, name, profileImage, textSize }: Props) {
const textClassName = `${textSize ? textSize : "text-xs"} font-medium`
return (
<div className="flex items-center space-x-2">
<p className={textClassName}>{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="rounded-full h-auto border-[1px] border-gray-400 border-opacity-50"
/>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import { EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { AiOutlineUserAdd } from "react-icons/ai";
import { BsTrash } from "react-icons/bs";
import { LuGripHorizontal } from "react-icons/lu";
import WorkflowStepNumber from "./WorkflowStepNumber";
import WorkflowStepSelects from "./WorkflowStepSelects";
interface Props extends Pick<EditableWorkflowStep, 'stepNumber' | 'assignees' | 'finalStep' | 'onDelete'> {
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean,
}
export default function WorkflowEditableStepComponent({
stepNumber,
assignees = [null],
finalStep,
onDelete,
onSelectChange,
entityApprovers,
isCompleted,
}: Props) {
const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]);
const [isAdding, setIsAdding] = useState(false);
const approverOptions: Option[] = useMemo(() =>
entityApprovers
.map((approver) => ({
value: approver.id,
label: approver.name,
icon: () => <img src={approver.profilePicture} alt={approver.name} />
}))
.sort((a, b) => a.label.localeCompare(b.label)),
[entityApprovers]
);
useEffect(() => {
if (assignees && assignees.length > 0) {
const initialSelects = assignees.map((assignee) =>
typeof assignee === 'string' ? approverOptions.find(option => option.value === assignee) || null : null
);
setSelects((prevSelects) => {
// This is needed to avoid unnecessary re-renders which can cause warning of a child component being re-rendered while parent is in the midle of also re-rendering.
const areEqual = initialSelects.length === prevSelects.length && initialSelects.every((option, idx) => option?.value === prevSelects[idx]?.value);
if (!areEqual) {
return initialSelects;
}
return prevSelects;
});
}
}, [assignees, approverOptions]);
const selectedValues = useMemo(() =>
selects.filter((opt): opt is Option => !!opt).map(opt => opt.value),
[selects]
);
const availableApproverOptions = useMemo(() =>
approverOptions.filter(opt => !selectedValues.includes(opt.value)),
[approverOptions, selectedValues]
);
const handleAddSelectComponent = () => {
setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender.
setSelects(prev => [...prev, null]);
};
useEffect(() => {
if (isAdding) {
onSelectChange(selects.length, selects.length - 1, null);
setIsAdding(false);
}
}, [selects.length, isAdding, onSelectChange]);
const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => {
const updated = [...selects];
updated[index] = option;
setSelects(updated);
onSelectChange(numberOfSelects, index, option);
};
return (
<div className="flex w-full">
<div className="flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} completed={false} selected={false} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="w-1 h-full min-h-10 bg-mti-purple-dark"></div>
)}
</div>
{stepNumber !== 1 && !finalStep && !isCompleted
? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing min-w-[25px] min-h-[25px]" />
: <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div>
}
<div className="ml-10 mb-12">
<WorkflowStepSelects
approvers={availableApproverOptions}
selects={selects}
placeholder={stepNumber === 1 ? "Form Intake By:" : "Approval By:"}
onSelectChange={handleSelectChangeAt}
isCompleted={isCompleted}
/>
</div>
<div className="flex flex-row items-start mt-1.5 ml-3">
<button
type="button"
onClick={handleAddSelectComponent}
className="cursor-pointer"
>
<AiOutlineUserAdd className="size-7 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{stepNumber !== 1 && !finalStep && (
<button
className="cursor-pointer"
onClick={onDelete}
type="button"
>
<BsTrash className="size-6 mt-0.5 ml-3 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,203 @@
import { EditableApprovalWorkflow, EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import { AnimatePresence, Reorder, motion } from "framer-motion";
import { FaRegCheckCircle, FaSpinner } from "react-icons/fa";
import { IoIosAddCircleOutline } from "react-icons/io";
import Button from "../Low/Button";
import Tip from "./Tip";
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
interface Props {
workflow: EditableApprovalWorkflow;
onWorkflowChange: (workflow: EditableApprovalWorkflow) => void;
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
entityAvailableFormIntakers?: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
isLoading: boolean;
isRedirecting?: boolean;
}
export default function WorkflowForm({ workflow, onWorkflowChange, entityApprovers, entityAvailableFormIntakers, isLoading, isRedirecting }: Props) {
const lastStep = workflow.steps[workflow.steps.length - 1];
const renumberSteps = (steps: EditableWorkflowStep[]): EditableWorkflowStep[] => {
return steps.map((step, index) => ({
...step,
stepNumber: index + 1,
}));
};
const addStep = () => {
const newStep: EditableWorkflowStep = {
key: Date.now(),
stepType: "approval-by",
stepNumber: workflow.steps.length,
completed: false,
assignees: [null],
firstStep: false,
finalStep: false,
};
const updatedSteps = [
...workflow.steps.slice(0, -1),
newStep,
lastStep
];
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleDelete = (key: number | undefined) => {
if (!key) return;
const updatedSteps = workflow.steps.filter((step) => step.key !== key);
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => {
if (!key) return;
const updatedSteps = workflow.steps.map((step) => {
if (step.key !== key) return step;
const assignees = step.assignees ?? [];
let newAssignees = [...assignees];
if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed
newAssignees[index] = selectedOption?.value;
} else if (numberOfSelects === assignees.length + 1) { // means a new select was added
newAssignees.push(selectedOption?.value || null);
}
return { ...step, assignees: newAssignees };
});
onWorkflowChange({ ...workflow, steps: updatedSteps });
};
const handleReorder = (newOrder: EditableWorkflowStep[]) => {
let draggableIndex = 0;
const updatedSteps = workflow.steps.map((step) => {
if (!step.firstStep && !step.finalStep && !step.completed) {
return newOrder[draggableIndex++];
}
// Keep static steps as-is
return step;
});
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
return (
<>
{workflow.entityId && workflow.name &&
<div>
<div
className="flex flex-col gap-6"
>
<Tip text="Introduce here all the steps associated with this instance." />
<Button
color="purple"
variant="solid"
onClick={addStep}
type="button"
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left mb-7"
>
<IoIosAddCircleOutline className="size-6" />
Add Step
</Button>
</div>
<Reorder.Group
axis="y"
values={workflow.steps}
onReorder={handleReorder}
className="flex flex-col gap-0"
>
<AnimatePresence>
{workflow.steps.map((step, index) =>
step.completed || step.firstStep || step.finalStep ? (
<motion.div
key={step.key}
layout
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</motion.div>
) : (
// Render non-completed steps as draggable items
<Reorder.Item
key={step.key}
value={step}
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
layout
drag={!step.firstStep && !step.finalStep}
dragListener={!step.firstStep && !step.finalStep}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</Reorder.Item>
)
)}
<Button
type="submit"
color="purple"
variant="solid"
disabled={isLoading}
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left -mt-4"
>
{isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Redirecting...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<FaRegCheckCircle className="size-5" />
Confirm Exam Workflow Pipeline
</>
)}
</Button>
</AnimatePresence>
</Reorder.Group>
</div>
}
</>
);
};

View File

@@ -0,0 +1,101 @@
import { getUserTypeLabel, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
import WorkflowStepNumber from "./WorkflowStepNumber";
import clsx from "clsx";
import { RiThumbUpLine } from "react-icons/ri";
import { FaWpforms } from "react-icons/fa6";
import { User } from "@/interfaces/user";
import UserWithProfilePic from "./UserWithProfilePic";
interface Props extends WorkflowStep {
workflowAssignees: User[],
currentStep: boolean,
}
export default function WorkflowStepComponent({
workflowAssignees,
currentStep,
stepType,
stepNumber,
completed,
rejected = false,
completedBy,
assignees,
finalStep,
selected = false,
onClick,
}: Props) {
const completedByUser = workflowAssignees.find((assignee) => assignee.id === completedBy);
const assigneesUsers = workflowAssignees.filter(user => assignees.includes(user.id));
return (
<div
onClick={onClick}
className={clsx("flex flex-row gap-5 w-[600px] p-6 mb-5 rounded-2xl transition ease-in-out duration-300 cursor-pointer", {
"bg-mti-red-ultralight": rejected && selected,
"bg-mti-purple-ultralight": selected,
})}
>
<div className="relative flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} selected={selected} completed={completed} finalStep={finalStep} rejected={rejected} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="absolute w-1 bg-mti-purple-dark -bottom-20 top-11"></div>
)}
</div>
<div className="mt-1.5">
{stepType === "approval-by" ? (
<RiThumbUpLine size={25} />
) : (
<FaWpforms size={25} />
)
}
</div>
<div className="mt-1 flex flex-col gap-0">
{completed && completedBy && rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Rejected by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : completed && completedBy && !rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Completed by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : !completed && currentStep ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
In Progress... Assignees:
<div className="flex flex-row flex-wrap gap-3 items-center">
{assigneesUsers.map(user => (
<span key={user.id}>
<UserWithProfilePic
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
) : (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
Waiting for previous steps...
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import { WorkflowStep } from "@/interfaces/approval.workflow";
import clsx from "clsx";
import { IoCheckmarkDoneSharp, IoCheckmarkSharp } from "react-icons/io5";
import { RxCross2 } from "react-icons/rx";
type Props = Pick<WorkflowStep, 'stepNumber' | 'completed' | 'finalStep' | 'selected' | 'rejected'>
export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) {
return (
<div
className={clsx(
'flex items-center justify-center min-w-11 min-h-11 rounded-full',
{
'bg-mti-red-dark text-mti-red-ultralight': rejected,
'bg-mti-purple-dark text-mti-purple-ultralight': selected,
'bg-mti-purple-ultralight text-gray-500': !selected,
}
)}
>
{rejected ? (
<RxCross2 className="text-xl font-bold" size={25}/>
) : completed && finalStep ? (
<IoCheckmarkDoneSharp className="text-xl font-bold" size={25} />
) : completed && !finalStep ? (
<IoCheckmarkSharp className="text-xl font-bold" size={25} />
) : (
<span className="text-lg font-semibold">{stepNumber}</span>
)}
</div>
);
};

View File

@@ -0,0 +1,51 @@
import Option from "@/interfaces/option";
import Select from "../Low/Select";
interface Props {
approvers: Option[];
selects: (Option | null | undefined)[];
placeholder: string;
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean;
}
export default function WorkflowStepSelects({
approvers,
selects,
placeholder,
onSelectChange,
isCompleted,
}: Props) {
return (
<div
className={"flex flex-wrap gap-0"}
>
{selects.map((option, index) => {
let classes = "px-2 rounded-none";
if (index === 0 && selects.length === 1) {
classes += " rounded-l-2xl rounded-r-2xl";
} else if (index === 0) {
classes += " rounded-l-2xl";
} else if (index === selects.length - 1) {
classes += " rounded-r-2xl";
}
return (
<div key={index} className="w-[275px]">
<Select
options={approvers}
value={option}
onChange={(option) => onSelectChange(selects.length, index, option)}
placeholder={placeholder}
flat
isClearable
className={classes}
disabled={isCompleted}
/>
</div>
);
})}
</div>
);
}

View File

@@ -95,7 +95,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
}, [updateLocalAndScheduleGlobal]); }, [updateLocalAndScheduleGlobal]);
return ( return (
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}> <div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit -2xl:w-full`}>
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div> <div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Dropdown <Dropdown

View File

@@ -17,6 +17,7 @@ import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components"; import ReadingComponents from "./reading/components";
import SpeakingComponents from "./speaking/components"; import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker"; import SectionPicker from "./Shared/SectionPicker";
import { getExamById } from "@/utils/exams";
const LevelSettings: React.FC = () => { const LevelSettings: React.FC = () => {
@@ -194,7 +195,7 @@ const LevelSettings: React.FC = () => {
category: s.settings.category category: s.settings.category
}; };
}).filter(part => part.exercises.length > 0), }).filter(part => part.exercises.length > 0),
isDiagnostic: false, isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer, minTimer,
module: "level", module: "level",
id: title, id: title,
@@ -213,6 +214,36 @@ const LevelSettings: React.FC = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}); });
const requestBody = await (async () => {
const handledExam = await getExamById("level", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "level"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} catch (error: any) { } catch (error: any) {
console.error('Error submitting exam:', error); console.error('Error submitting exam:', error);
toast.error( toast.error(

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 gap-2 items-center px-2 pb-4"> <div className="flex flex-row flex-wrap gap-2 items-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

@@ -17,6 +17,7 @@ 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();
@@ -137,7 +138,7 @@ const ListeningSettings: React.FC = () => {
category: s.settings.category category: s.settings.category
}; };
}), }),
isDiagnostic: false, isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer, minTimer,
module: "listening", module: "listening",
id: title, id: title,
@@ -151,6 +152,36 @@ const ListeningSettings: React.FC = () => {
playSound("sent"); playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`); toast.success(`Submitted Exam ID: ${result.data.id}`);
const requestBody = await (async () => {
const handledExam = await getExamById("listening", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "listening"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} else { } else {
toast.error('No audio sections found in the exam! Please either import them or generate them.'); toast.error('No audio sections found in the exam! Please either import them or generate them.');
} }

View File

@@ -5,24 +5,36 @@ import ExercisePicker from "../../ExercisePicker";
import { generate } from "../Shared/Generate"; import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn"; import GenerateBtn from "../Shared/GenerateBtn";
import { LevelPart, ReadingPart } from "@/interfaces/exam"; import { LevelPart, ReadingPart } from "@/interfaces/exam";
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types"; import {
LevelSectionSettings,
ReadingSectionSettings,
} from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
interface Props { interface Props {
localSettings: ReadingSectionSettings | LevelSectionSettings; localSettings: ReadingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void; updateLocalAndScheduleGlobal: (
updates: Partial<ReadingSectionSettings | LevelSectionSettings>,
schedule?: boolean
) => void;
currentSection: ReadingPart | LevelPart; currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean; generatePassageDisabled?: boolean;
levelId?: number; levelId?: number;
level?: boolean; level?: boolean;
} }
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => { const ReadingComponents: React.FC<Props> = ({
localSettings,
updateLocalAndScheduleGlobal,
currentSection,
levelId,
level = false,
generatePassageDisabled = false,
}) => {
const { currentModule } = useExamEditorStore(); const { currentModule } = useExamEditorStore();
const { const { focusedSection, difficulty } = useExamEditorStore(
focusedSection, (state) => state.modules[currentModule]
difficulty, );
} = useExamEditorStore(state => state.modules[currentModule]);
const generatePassage = useCallback(() => { const generatePassage = useCallback(() => {
generate( generate(
@@ -30,25 +42,32 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
"reading", "reading",
"passage", "passage",
{ {
method: 'GET', method: "GET",
queryParams: { queryParams: {
difficulty, difficulty,
...(localSettings.readingTopic && { topic: localSettings.readingTopic }) ...(localSettings.readingTopic && {
} topic: localSettings.readingTopic,
}),
}, },
(data: any) => [{ },
(data: any) => [
{
title: data.title, title: data.title,
text: data.text text: data.text,
}], },
],
level ? focusedSection : undefined, level ? focusedSection : undefined,
level level
); );
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]); }, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
const onTopicChange = useCallback((readingTopic: string) => { const onTopicChange = useCallback(
(readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic }); updateLocalAndScheduleGlobal({ readingTopic });
}, [updateLocalAndScheduleGlobal]); },
[updateLocalAndScheduleGlobal]
);
return ( return (
<> <>
@@ -56,13 +75,19 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
title="Generate Passage" title="Generate Passage"
module="reading" module="reading"
open={localSettings.isPassageOpen} open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) =>
contentWrapperClassName={level ? `border border-ielts-reading`: ''} updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)
}
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={generatePassageDisabled} disabled={generatePassageDisabled}
> >
<div className="flex flex-row gap-2 items-center px-2 pb-4"> <div
className="flex flex-row flex-wrap gap-2 items-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
key={`section-${focusedSection}`} key={`section-${focusedSection}`}
type="text" type="text"
@@ -88,14 +113,26 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
title="Add Exercises" title="Add Exercises"
module="reading" module="reading"
open={localSettings.isReadingTopicOpean} open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })} setIsOpen={(isOpen: boolean) =>
contentWrapperClassName={level ? `border border-ielts-reading`: ''} updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""} }
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={
currentSection === undefined ||
currentSection.text === undefined ||
currentSection.text.content === "" ||
currentSection.text.title === ""
}
> >
<ExercisePicker <ExercisePicker
module="reading" module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection} sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }} extraArgs={{
text:
currentSection === undefined || currentSection.text === undefined
? ""
: currentSection.text.content,
}}
levelSectionId={focusedSection} levelSectionId={focusedSection}
level={level} level={level}
/> />

View File

@@ -12,6 +12,7 @@ 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();
@@ -75,7 +76,7 @@ const ReadingSettings: React.FC = () => {
category: localSettings.category category: localSettings.category
}; };
}), }),
isDiagnostic: false, isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer, minTimer,
module: "reading", module: "reading",
id: title, id: title,
@@ -89,11 +90,37 @@ const ReadingSettings: React.FC = () => {
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`); toast.success(`Submitted Exam ID: ${result.data.id}`);
return getExamById("reading", result.data.id);
})
.then((handledExam) => {
const requestBody = {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "reading"
};
return axios.post(`/api/approval-workflows`, requestBody);
})
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
}) })
.catch((error) => { .catch((error) => {
console.log(error); if (error.response && error.response.status === 404) {
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); toast.error("No configured workflow found for examAuthor for any of its entities.");
}) } else {
toast.error(
error.response?.data?.error ||
"Something went wrong, please try again later."
);
}
});
} }
const preview = () => { const preview = () => {

View File

@@ -11,6 +11,7 @@ import openDetachedTab from "@/utils/popout";
import axios from "axios"; import axios from "axios";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import SpeakingComponents from "./components"; import SpeakingComponents from "./components";
import { getExamById } from "@/utils/exams";
export interface Avatar { export interface Avatar {
name: string; name: string;
@@ -180,7 +181,7 @@ const SpeakingSettings: React.FC = () => {
minTimer, minTimer,
module: "speaking", module: "speaking",
id: title, id: title,
isDiagnostic: false, isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
variant: undefined, variant: undefined,
difficulty, difficulty,
instructorGender: "varied", instructorGender: "varied",
@@ -195,6 +196,36 @@ const SpeakingSettings: React.FC = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}); });
const requestBody = await (async () => {
const handledExam = await getExamById("speaking", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "speaking"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} catch (error: any) { } catch (error: any) {
toast.error( toast.error(
"Something went wrong while submitting, please try again later." "Something went wrong while submitting, please try again later."

View File

@@ -12,6 +12,8 @@ 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 WritingComponents from "./components"; import WritingComponents from "./components";
import { getExamById } from "@/utils/exams";
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
const WritingSettings: React.FC = () => { const WritingSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();
@@ -129,7 +131,7 @@ const WritingSettings: React.FC = () => {
minTimer, minTimer,
module: "writing", module: "writing",
id: title, id: title,
isDiagnostic: false, isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, private: isPrivate,
@@ -140,6 +142,36 @@ const WritingSettings: React.FC = () => {
playSound("sent"); playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`); toast.success(`Submitted Exam ID: ${result.data.id}`);
const requestBody = await (async () => {
const handledExam = await getExamById("writing", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "writing"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} catch (error: any) { } catch (error: any) {
console.error('Error submitting exam:', error); console.error('Error submitting exam:', error);
toast.error( toast.error(

View File

@@ -19,8 +19,8 @@ const label = (type: string, firstId: string, lastId: string) => {
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => { const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
return ( return (
<div className="flex w-full justify-between items-center mr-4"> <div className="flex w-full justify-between items-center mr-4">
<span className="font-semibold">{label(type, firstId, lastId)}</span> <span className="font-semibold ellipsis-2">{label(type, firstId, lastId)}</span>
<div className="text-sm font-light italic">{previewLabel(prompt)}</div> <div className="text-sm font-light italic ellipsis-2">{previewLabel(prompt)}</div>
</div> </div>
); );
} }

View File

@@ -33,10 +33,12 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
isPrivate, isPrivate,
difficulty, difficulty,
sectionLabels, sectionLabels,
importModule importModule,
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore((state) => state.modules[currentModule]);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1); const [numberOfLevelParts, setNumberOfLevelParts] = useState(
levelParts !== 0 ? levelParts : 1
);
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
// For exam edits // For exam edits
@@ -44,34 +46,39 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
if (levelParts !== 0) { if (levelParts !== 0) {
setNumberOfLevelParts(levelParts); setNumberOfLevelParts(levelParts);
dispatch({ dispatch({
type: 'UPDATE_MODULE', type: "UPDATE_MODULE",
payload: { payload: {
updates: { updates: {
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({ sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
id: i + 1, id: i + 1,
label: `Part ${i + 1}` label: `Part ${i + 1}`,
})) })),
}, },
module: "level" module: "level",
} },
}) });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelParts]) }, [levelParts]);
useEffect(() => { useEffect(() => {
const currentSections = sections; const currentSections = sections;
const currentLabels = sectionLabels; const currentLabels = sectionLabels;
let updatedSections: SectionState[]; let updatedSections: SectionState[];
let updatedLabels: any; let updatedLabels: any;
if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) { if (
(currentModule === "level" &&
currentSections.length !== currentLabels.length) ||
numberOfLevelParts !== currentSections.length
) {
const newSections = [...currentSections]; const newSections = [...currentSections];
const newLabels = [...currentLabels]; const newLabels = [...currentLabels];
for (let i = currentLabels.length; i < numberOfLevelParts; i++) { for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1)); if (currentSections.length !== numberOfLevelParts)
newSections.push(defaultSectionSettings(currentModule, i + 1));
newLabels.push({ newLabels.push({
id: i + 1, id: i + 1,
label: `Part ${i + 1}` label: `Part ${i + 1}`,
}); });
} }
updatedSections = newSections; updatedSections = newSections;
@@ -83,35 +90,38 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
return; return;
} }
const updatedExpandedSections = expandedSections.filter( const updatedExpandedSections = expandedSections.filter((sectionId) =>
sectionId => updatedSections.some(section => section.sectionId === sectionId) updatedSections.some((section) => section.sectionId === sectionId)
); );
dispatch({ dispatch({
type: 'UPDATE_MODULE', type: "UPDATE_MODULE",
payload: { payload: {
updates: { updates: {
sections: updatedSections, sections: updatedSections,
sectionLabels: updatedLabels, sectionLabels: updatedLabels,
expandedSections: updatedExpandedSections expandedSections: updatedExpandedSections,
} },
} },
}); });
// 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 = sections.map((section) => section.sectionId);
const updateModule = useCallback((updates: Partial<ModuleState>) => { const updateModule = useCallback(
dispatch({ type: 'UPDATE_MODULE', payload: { updates } }); (updates: Partial<ModuleState>) => {
}, [dispatch]); dispatch({ type: "UPDATE_MODULE", payload: { updates } });
},
[dispatch]
);
const toggleSection = (sectionId: number) => { const toggleSection = (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 } });
}; };
const ModuleSettings: Record<Module, React.ComponentType> = { const ModuleSettings: Record<Module, React.ComponentType> = {
@@ -119,57 +129,84 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
writing: WritingSettings, writing: WritingSettings,
speaking: SpeakingSettings, speaking: SpeakingSettings,
listening: ListeningSettings, listening: ListeningSettings,
level: LevelSettings level: LevelSettings,
}; };
const Settings = ModuleSettings[currentModule]; const Settings = ModuleSettings[currentModule];
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule); const showImport =
importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => { const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts); setNumberOfLevelParts(parts);
} };
return ( return (
<> <>
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> : ( {showImport ? (
<ImportOrStartFromScratch
module={currentModule}
setNumberOfLevelParts={updateLevelParts}
/>
) : (
<> <>
{isResetModuleOpen && <ResetModule module={currentModule} isOpen={isResetModuleOpen} setIsOpen={setIsResetModuleOpen} setNumberOfLevelParts={setNumberOfLevelParts}/>} {isResetModuleOpen && (
<div className="flex gap-4 w-full items-center"> <ResetModule
module={currentModule}
isOpen={isResetModuleOpen}
setIsOpen={setIsResetModuleOpen}
setNumberOfLevelParts={setNumberOfLevelParts}
/>
)}
<div className="flex gap-4 w-full items-center -xl:flex-col">
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">
Timer
</label>
<Input <Input
type="number" type="number"
name="minTimer" name="minTimer"
onChange={(e) => updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })} onChange={(e) =>
updateModule({
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
})
}
value={minTimer} value={minTimer}
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<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">Difficulty</label> <label className="font-normal text-base text-mti-gray-dim">
Difficulty
</label>
<Select <Select
isMulti={true} isMulti={true}
options={DIFFICULTIES.map((x) => ({ options={DIFFICULTIES.map((x) => ({
value: x, value: x,
label: capitalize(x) label: capitalize(x),
}))} }))}
onChange={(values) => { onChange={(values) => {
const selectedDifficulties = values ? values.map(v => v.value as Difficulty) : []; const selectedDifficulties = values
? values.map((v) => v.value as Difficulty)
: [];
updateModule({ difficulty: selectedDifficulties }); updateModule({ difficulty: selectedDifficulties });
}} }}
value={ value={
difficulty difficulty
? difficulty.map(d => ({ ? difficulty.map((d) => ({
value: d, value: d,
label: capitalize(d) label: capitalize(d),
})) }))
: null : null
} }
/> />
</div> </div>
{(sectionLabels.length != 0 && currentModule !== "level") ? ( </div>
<div className="flex flex-col gap-3"> {sectionLabels.length != 0 && currentModule !== "level" ? (
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label> <div className="flex flex-col gap-3 -xl:w-full">
<label className="font-normal text-base text-mti-gray-dim">
{sectionLabels[0].label.split(" ")[0]}
</label>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-8">
{sectionLabels.map(({ id, label }) => ( {sectionLabels.map(({ id, label }) => (
<span <span
@@ -188,23 +225,35 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
))} ))}
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3 w-1/3"> <div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label> <label className="font-normal text-base text-mti-gray-dim">
<Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} /> Number of Parts
</label>
<Input
type="number"
name="Number of Parts"
min={1}
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
value={numberOfLevelParts}
/>
</div> </div>
)} )}
<div className="flex flex-col gap-3 w-fit h-fit"> <div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" /> <div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={(checked) => updateModule({ isPrivate: checked })}> <Checkbox
isChecked={isPrivate}
onChange={(checked) => updateModule({ isPrivate: checked })}
>
Privacy (Only available for Assignments) Privacy (Only available for Assignments)
</Checkbox> </Checkbox>
</div> </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">Exam Label *</label> <label className="font-normal text-base text-mti-gray-dim">
Exam Label *
</label>
<Input <Input
type="text" type="text"
placeholder="Exam Label" placeholder="Exam Label"
@@ -224,9 +273,9 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
Reset Module Reset Module
</Button> </Button>
</div> </div>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-8 -2xl:flex-col">
<Settings /> <Settings />
<div className="flex-grow max-w-[66%]"> <div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer /> <SectionRenderer />
</div> </div>
</div> </div>

View File

@@ -24,7 +24,7 @@ const DroppableQuestionArea: React.FC<DroppableQuestionAreaProps> = ({ question,
</div> </div>
<div <div
key={`answer_${question.id}_${answer}`} key={`answer_${question.id}_${answer}`}
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}> className={clsx("w-48 h-10 border-2 border-mti-purple-light self-center rounded-xl flex items-center justify-center", isOver && "border-mti-purple-dark")}>
{answer && `Paragraph ${answer}`} {answer && `Paragraph ${answer}`}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,10 @@
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments"; import {
activeAssignmentFilter,
futureAssignmentFilter,
} from "@/utils/assignments";
import { sortByModuleName } from "@/utils/moduleUtils"; import { sortByModuleName } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
@@ -11,27 +14,38 @@ import Button from "../Low/Button";
import ModuleBadge from "../ModuleBadge"; import ModuleBadge from "../ModuleBadge";
interface Props { interface Props {
assignment: Assignment assignment: Assignment;
user: User user: User;
session?: Session session?: Session;
startAssignment: (assignment: Assignment) => void startAssignment: (assignment: Assignment) => void;
resumeAssignment: (session: Session) => void resumeAssignment: (session: Session) => void;
} }
export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) { export default function AssignmentCard({
const router = useRouter() user,
assignment,
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id]) session,
startAssignment,
resumeAssignment,
}: Props) {
const hasBeenSubmitted = useMemo(
() => assignment.results.map((r) => r.user).includes(user.id),
[assignment.results, user.id]
);
return ( return (
<div <div
className={clsx( className={clsx(
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4", "border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light", assignment.results.map((r) => r.user).includes(user.id) &&
"border-mti-green-light"
)} )}
key={assignment.id}> key={assignment.id}
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3> <h3 className="text-mti-black/90 text-xl font-semibold">
{assignment.name}
</h3>
<span className="flex justify-between gap-1 text-lg"> <span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span> <span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span> <span>-</span>
@@ -45,7 +59,11 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
.map((e) => e.module) .map((e) => e.module)
.sort(sortByModuleName) .sort(sortByModuleName)
.map((module) => ( .map((module) => (
<ModuleBadge className="scale-110 w-full" key={module} module={module} /> <ModuleBadge
className="scale-110 w-full"
key={module}
module={module}
/>
))} ))}
</div> </div>
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && ( {futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
@@ -53,7 +71,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
color="rose" color="rose"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled disabled
variant="outline"> variant="outline"
>
Not yet started Not yet started
</Button> </Button>
)} )}
@@ -61,7 +80,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<> <>
<div <div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden" className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"> data-tip="Your screen size is too small to perform an assignment"
>
<Button className="h-full w-full !rounded-xl" variant="outline"> <Button className="h-full w-full !rounded-xl" variant="outline">
Start Start
</Button> </Button>
@@ -71,12 +91,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
data-tip="You have already started this assignment!" data-tip="You have already started this assignment!"
className={clsx( className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer", "-md:hidden h-full w-full max-w-[50%] cursor-pointer",
!!session && "tooltip", !!session && "tooltip"
)}> )}
>
<Button <Button
className={clsx("w-full h-full !rounded-xl")} className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)} onClick={() => startAssignment(assignment)}
variant="outline"> variant="outline"
>
Start Start
</Button> </Button>
</div> </div>
@@ -85,12 +107,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<div <div
className={clsx( className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer" "-md:hidden h-full w-full max-w-[50%] cursor-pointer"
)}> )}
>
<Button <Button
className={clsx("w-full h-full !rounded-xl")} className={clsx("w-full h-full !rounded-xl")}
onClick={() => resumeAssignment(session)} onClick={() => resumeAssignment(session)}
color="green" color="green"
variant="outline"> variant="outline"
>
Resume Resume
</Button> </Button>
</div> </div>
@@ -102,11 +126,12 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
color="green" color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled disabled
variant="outline"> variant="outline"
>
Submitted Submitted
</Button> </Button>
)} )}
</div> </div>
</div> </div>
) );
} }

View File

@@ -2,8 +2,6 @@ import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import { clsx } from "clsx"; import { clsx } from "clsx";
import {ReactNode} from "react"; import {ReactNode} from "react";
import Checkbox from "../Low/Checkbox";
import Separator from "../Low/Separator";
interface Props<T> { interface Props<T> {
list: T[]; list: T[];

View File

@@ -1,4 +1,3 @@
import useEntities from "@/hooks/useEntities";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
@@ -6,34 +5,92 @@ import { useRouter } from "next/router";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Navbar from "../Navbar"; import Navbar from "../Navbar";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
import React, { useEffect, useState } from "react";
export const LayoutContext = React.createContext({
onFocusLayerMouseEnter: () => {},
setOnFocusLayerMouseEnter: (() => {}) as React.Dispatch<
React.SetStateAction<() => void>
>,
navDisabled: false,
setNavDisabled: (() => {}) as React.Dispatch<React.SetStateAction<boolean>>,
focusMode: false,
setFocusMode: (() => {}) as React.Dispatch<React.SetStateAction<boolean>>,
hideSidebar: false,
setHideSidebar: (() => {}) as React.Dispatch<React.SetStateAction<boolean>>,
bgColor: "bg-white",
setBgColor: (() => {}) as React.Dispatch<React.SetStateAction<string>>,
className: "",
setClassName: (() => {}) as React.Dispatch<React.SetStateAction<string>>,
});
interface Props { interface Props {
user: User; user: User;
entities?: EntityWithRoles[] entities?: EntityWithRoles[];
children: React.ReactNode; children: React.ReactNode;
className?: string; refreshPage?: boolean;
navDisabled?: boolean;
focusMode?: boolean;
hideSidebar?: boolean
bgColor?: string;
onFocusLayerMouseEnter?: () => void;
} }
export default function Layout({ export default function Layout({
user, user,
entities,
children, children,
className, refreshPage,
bgColor = "bg-white",
hideSidebar,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter
}: Props) { }: Props) {
const [onFocusLayerMouseEnter, setOnFocusLayerMouseEnter] = useState(
() => () => {}
);
const [navDisabled, setNavDisabled] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [hideSidebar, setHideSidebar] = useState(false);
const [bgColor, setBgColor] = useState("bg-white");
const [className, setClassName] = useState("");
useEffect(() => {
if (refreshPage) {
setClassName("");
setBgColor("bg-white");
setFocusMode(false);
setHideSidebar(false);
setNavDisabled(false);
setOnFocusLayerMouseEnter(() => () => {});
}
}, [refreshPage]);
const LayoutContextValue = React.useMemo(
() => ({
onFocusLayerMouseEnter,
setOnFocusLayerMouseEnter,
navDisabled,
setNavDisabled,
focusMode,
setFocusMode,
hideSidebar,
setHideSidebar,
bgColor,
setBgColor,
className,
setClassName,
}),
[
bgColor,
className,
focusMode,
hideSidebar,
navDisabled,
onFocusLayerMouseEnter,
]
);
const router = useRouter(); const router = useRouter();
const { entities } = useEntities()
return ( return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}> <LayoutContext.Provider value={LayoutContextValue}>
<main
className={clsx(
"w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative"
)}
>
<ToastContainer /> <ToastContainer />
{!hideSidebar && user && ( {!hideSidebar && user && (
<Navbar <Navbar
@@ -61,11 +118,13 @@ export default function Layout({
`w-full min-h-full ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`, `w-full min-h-full ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
bgColor !== "bg-white" ? "justify-center" : "h-fit", bgColor !== "bg-white" ? "justify-center" : "h-fit",
hideSidebar ? "md:mx-8" : "md:mr-8", hideSidebar ? "md:mx-8" : "md:mr-8",
className, className
)}> )}
>
{children} {children}
</div> </div>
</div> </div>
</main> </main>
</LayoutContext.Provider>
); );
} }

View File

@@ -1,27 +1,49 @@
import { useListSearch } from "@/hooks/useListSearch" import { useListSearch } from "@/hooks/useListSearch";
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table" import {
import clsx from "clsx" ColumnDef,
import { useEffect, useState } from "react" flexRender,
import { BsArrowDown, BsArrowUp } from "react-icons/bs" getCoreRowModel,
import Button from "../Low/Button" getPaginationRowModel,
getSortedRowModel,
PaginationState,
useReactTable,
} from "@tanstack/react-table";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
import Button from "../Low/Button";
interface Props<T> { interface Props<T> {
data: T[] data: T[];
columns: ColumnDef<any, any>[] columns: ColumnDef<any, any>[];
searchFields: string[][] searchFields: string[][];
size?: number size?: number;
onDownload?: (rows: T[]) => void onDownload?: (rows: T[]) => void;
isDownloadLoading?: boolean isDownloadLoading?: boolean;
searchPlaceholder?: string searchPlaceholder?: string;
isLoading?: boolean;
} }
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload, isDownloadLoading, searchPlaceholder }: Props<T>) { export default function Table<T>({
data,
columns,
searchFields,
size = 16,
onDownload,
isDownloadLoading,
searchPlaceholder,
isLoading,
}: Props<T>) {
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: size, pageSize: size,
}) });
const { rows, renderSearch } = useListSearch<T>(searchFields, data, searchPlaceholder); const { rows, renderSearch } = useListSearch<T>(
searchFields,
data,
searchPlaceholder
);
const table = useReactTable({ const table = useReactTable({
data: rows, data: rows,
@@ -31,8 +53,8 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination, onPaginationChange: setPagination,
state: { state: {
pagination pagination,
} },
}); });
return ( return (
@@ -40,16 +62,24 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
<div className="w-full flex gap-2 items-end"> <div className="w-full flex gap-2 items-end">
{renderSearch()} {renderSearch()}
{onDownload && ( {onDownload && (
<Button isLoading={isDownloadLoading} className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}> <Button
isLoading={isDownloadLoading}
className="w-full max-w-[200px] mb-1"
variant="outline"
onClick={() => onDownload(rows)}
>
Download Download
</Button> </Button>
) )}
}
</div> </div>
<div className="w-full flex gap-2 justify-between items-center"> <div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}> <Button
className="w-[200px] h-fit"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
Previous Page Previous Page
</Button> </Button>
</div> </div>
@@ -57,12 +87,16 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<div>Page</div> <div>Page</div>
<strong> <strong>
{table.getState().pagination.pageIndex + 1} of{' '} {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount().toLocaleString()} {table.getPageCount().toLocaleString()}
</strong> </strong>
<div>| Total: {table.getRowCount().toLocaleString()}</div> <div>| Total: {table.getRowCount().toLocaleString()}</div>
</span> </span>
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}> <Button
className="w-[200px]"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
Next Page Next Page
</Button> </Button>
</div> </div>
@@ -73,9 +107,17 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}> <th
className="py-4 px-4 text-left"
key={header.id}
colSpan={header.colSpan}
>
<div <div
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')} className={clsx(
header.column.getCanSort() &&
"cursor-pointer select-none",
"flex items-center gap-2"
)}
onClick={header.column.getToggleSortingHandler()} onClick={header.column.getToggleSortingHandler()}
> >
{flexRender( {flexRender(
@@ -94,7 +136,10 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
</thead> </thead>
<tbody className="px-2 w-full"> <tbody className="px-2 w-full">
{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 items-center w-fit" key={cell.id}> <td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -104,6 +149,11 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
))} ))}
</tbody> </tbody>
</table> </table>
{isLoading && (
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div> </div>
) )}
</div>
);
} }

View File

@@ -0,0 +1,126 @@
import clsx from "clsx";
import { useEffect, useState } from "react";
import { GroupBase, StylesConfig } from "react-select";
import ReactSelect from "react-select";
import Option from "@/interfaces/option";
interface Props {
defaultValue?: Option | Option[];
options: Option[];
value?: Option | Option[] | null;
isLoading?: boolean;
loadOptions: (inputValue: string) => void;
onMenuScrollToBottom: (event: WheelEvent | TouchEvent) => void;
disabled?: boolean;
placeholder?: string;
isClearable?: boolean;
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
className?: string;
label?: string;
flat?: boolean;
}
interface MultiProps {
isMulti: true;
onChange: (value: Option[] | null) => void;
}
interface SingleProps {
isMulti?: false;
onChange: (value: Option | null) => void;
}
export default function AsyncSelect({
value,
isMulti,
defaultValue,
options,
loadOptions,
onMenuScrollToBottom,
placeholder,
disabled,
onChange,
styles,
isClearable,
isLoading,
label,
className,
flat,
}: Props & (MultiProps | SingleProps)) {
const [target, setTarget] = useState<HTMLElement>();
const [inputValue, setInputValue] = useState("");
//Implemented a debounce to prevent the API from being called too frequently
useEffect(() => {
const timer = setTimeout(() => {
loadOptions(inputValue);
}, 200);
return () => clearTimeout(timer);
}, [inputValue, loadOptions]);
useEffect(() => {
if (document) setTarget(document.body);
}, []);
return (
<div className="w-full flex flex-col gap-3">
{label && (
<label className="font-normal text-base text-mti-gray-dim">
{label}
</label>
)}
<ReactSelect
isMulti={isMulti}
className={
styles
? undefined
: clsx(
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full border bg-white text-sm font-normal focus:outline-none",
disabled &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
flat ? "rounded-md" : "px-4 py-4 rounded-full",
className
)
}
isLoading={isLoading}
filterOption={null}
loadingMessage={() => "Loading..."}
onInputChange={(inputValue) => {
setInputValue(inputValue);
}}
options={options}
value={value}
onChange={onChange as any}
placeholder={placeholder}
menuPortalTarget={target}
defaultValue={defaultValue}
onMenuScrollToBottom={onMenuScrollToBottom}
styles={
styles || {
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}
}
isDisabled={disabled}
isClearable={isClearable}
/>
</div>
);
}

View File

@@ -9,21 +9,22 @@ import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import useUsersSelect from "../../hooks/useUsersSelect";
import AsyncSelect from "../Low/AsyncSelect";
type TimeFilter = "months" | "weeks" | "days"; type TimeFilter = "months" | "weeks" | "days";
type Filter = TimeFilter | "assignments" | undefined; type Filter = TimeFilter | "assignments" | undefined;
interface Props { interface Props {
user: User; user: User;
entities: EntityWithRoles[] entities: EntityWithRoles[];
users: User[] isAdmin?: boolean;
filterState: { filterState: {
filter: Filter, filter: Filter;
setFilter: React.Dispatch<React.SetStateAction<Filter>> setFilter: React.Dispatch<React.SetStateAction<Filter>>;
}, };
assignments?: boolean; assignments?: boolean;
children?: ReactNode children?: ReactNode;
} }
const defaultSelectableCorporate = { const defaultSelectableCorporate = {
@@ -34,25 +35,42 @@ const defaultSelectableCorporate = {
const RecordFilter: React.FC<Props> = ({ const RecordFilter: React.FC<Props> = ({
user, user,
entities, entities,
users,
filterState, filterState,
assignments = true, assignments = true,
children isAdmin = false,
children,
}) => { }) => {
const { filter, setFilter } = filterState; const { filter, setFilter } = filterState;
const [entity, setEntity] = useState<string>() const [entity, setEntity] = useState<string>();
const [, setStatsUserId] = useRecordStore((state) => [ const [, setStatsUserId] = useRecordStore((state) => [
state.selectedUser, state.selectedUser,
state.setSelectedUser state.setSelectedUser,
]); ]);
const allowedViewEntities = useAllowedEntities(user, entities, 'view_student_record') const entitiesToSearch = useMemo(() => {
if(entity) return entity
if (isAdmin) return undefined;
return mapBy(entities, "id");
}, [entities, entity, isAdmin]);
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity]) const { users, isLoading, onScrollLoadMoreOptions, loadOptions } =
useUsersSelect({
size: 50,
orderBy: "name",
direction: "asc",
entities: entitiesToSearch,
});
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]) const allowedViewEntities = useAllowedEntities(
user,
entities,
"view_student_record"
);
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
setFilter((prev) => (prev === value ? undefined : value)); setFilter((prev) => (prev === value ? undefined : value));
@@ -61,39 +79,59 @@ const RecordFilter: React.FC<Props> = ({
return ( return (
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center"> <div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4 flex gap-2"> <div className="xl:w-3/4 flex gap-2">
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && ( {checkAccess(user, ["developer", "admin", "mastercorporate"]) &&
!children && (
<> <>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<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
options={allowedViewEntities.map((e) => ({ value: e.id, label: e.label }))} options={allowedViewEntities.map((e) => ({
value: e.id,
label: e.label,
}))}
onChange={(value) => setEntity(value?.value || undefined)} onChange={(value) => setEntity(value?.value || undefined)}
isClearable isClearable
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
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,
}), }),
}} /> }}
/>
</div> </div>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">User</label> <label className="font-normal text-base text-mti-gray-dim">
User
</label>
<Select <AsyncSelect
options={entityUsers.map((x) => ({ isLoading={isLoading}
value: x.id, loadOptions={loadOptions}
label: `${x.name} - ${x.email}`, onMenuScrollToBottom={onScrollLoadMoreOptions}
}))} options={users}
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} defaultValue={{
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 }),
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,
}), }),
}} }}
@@ -101,23 +139,32 @@ const RecordFilter: React.FC<Props> = ({
</div> </div>
</> </>
)} )}
{(user.type === "corporate" || user.type === "teacher") && !children && ( {(user.type === "corporate" || user.type === "teacher") &&
!children && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">User</label> <label className="font-normal text-base text-mti-gray-dim">
User
</label>
<Select <AsyncSelect
options={users isLoading={isLoading}
.map((x) => ({ loadOptions={loadOptions}
value: x.id, onMenuScrollToBottom={onScrollLoadMoreOptions}
label: `${x.name} - ${x.email}`, options={users}
}))} defaultValue={{
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} 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 }),
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,
}), }),
}} }}
@@ -132,9 +179,10 @@ const RecordFilter: React.FC<Props> = ({
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
filter === "assignments" && "!bg-mti-purple-light !text-white", filter === "assignments" && "!bg-mti-purple-light !text-white"
)} )}
onClick={() => toggleFilter("assignments")}> onClick={() => toggleFilter("assignments")}
>
Assignments Assignments
</button> </button>
)} )}
@@ -142,32 +190,35 @@ const RecordFilter: React.FC<Props> = ({
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
filter === "months" && "!bg-mti-purple-light !text-white", filter === "months" && "!bg-mti-purple-light !text-white"
)} )}
onClick={() => toggleFilter("months")}> onClick={() => toggleFilter("months")}
>
Last month Last month
</button> </button>
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white", filter === "weeks" && "!bg-mti-purple-light !text-white"
)} )}
onClick={() => toggleFilter("weeks")}> onClick={() => toggleFilter("weeks")}
>
Last week Last week
</button> </button>
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white", filter === "days" && "!bg-mti-purple-light !text-white"
)} )}
onClick={() => toggleFilter("days")}> onClick={() => toggleFilter("days")}
>
Last day Last day
</button> </button>
</div> </div>
</div> </div>
); );
} };
export default RecordFilter; export default RecordFilter;

View File

@@ -0,0 +1,60 @@
import React from "react";
export default function UserProfileSkeleton() {
return (
<div className="bg-white min-h-screen p-6">
<div className="mt-6 bg-white p-6 rounded-lg flex gap-4 items-center">
<div className="h-64 w-60 bg-gray-300 animate-pulse rounded"></div>
<div className="flex-1">
<div className="h-12 w-64 bg-gray-300 animate-pulse rounded"></div>
<div className="flex justify-between items-center mt-1">
<div className="h-4 w-60 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-8 w-32 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
<div className="h-4 w-100 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="mt-6 grid grid-cols-4 justify-item-start gap-4">
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
<div className="bg-white p-4 rounded-lg text-center flex flex-row items-center justify-center">
<div className="h-12 w-12 mx-2 bg-gray-300 animate-pulse rounded"></div>
<div className="flex flex-col">
<div className="h-4 w-4 bg-gray-300 animate-pulse mt-2 rounded"></div>
<div className="h-4 w-16 bg-gray-300 animate-pulse mt-2 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-6 bg-white p-6 rounded-lg">
<div className="h-6 w-40 bg-gray-300 animate-pulse rounded mb-4"></div>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex justify-between items-center">
<div className="h-4 w-24 bg-gray-300 animate-pulse rounded"></div>
<div className="h-2 w-3/4 bg-gray-300 animate-pulse rounded"></div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { MdSpaceDashboard } from "react-icons/md";
import { import {
BsFileEarmarkText, BsFileEarmarkText,
BsClockHistory, BsClockHistory,
BsPencil,
BsGraphUp, BsGraphUp,
BsChevronBarRight, BsChevronBarRight,
BsChevronBarLeft, BsChevronBarLeft,
@@ -14,6 +13,7 @@ import {
BsClipboardData, BsClipboardData,
BsPeople, BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import { GoWorkflow } from "react-icons/go";
import { CiDumbbell } from "react-icons/ci"; import { CiDumbbell } from "react-icons/ci";
import { RiLogoutBoxFill } from "react-icons/ri"; import { RiLogoutBoxFill } from "react-icons/ri";
import Link from "next/link"; import Link from "next/link";
@@ -24,11 +24,15 @@ import { preventNavigation } from "@/utils/navigation.disabled";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener"; import useTicketsListener from "@/hooks/useTicketsListener";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { getTypesOfUser } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { useAllowedEntities, useAllowedEntitiesSomePermissions } from "@/hooks/useEntityPermissions"; import {
useAllowedEntities,
useAllowedEntitiesSomePermissions,
} from "@/hooks/useEntityPermissions";
import { useMemo } from "react"; import { useMemo } from "react";
import { PermissionType } from "../interfaces/permissions";
interface Props { interface Props {
path: string; path: string;
@@ -37,7 +41,7 @@ interface Props {
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
user: User; user: User;
entities?: EntityWithRoles[] entities?: EntityWithRoles[];
} }
interface NavProps { interface NavProps {
@@ -50,17 +54,28 @@ interface NavProps {
badge?: number; badge?: number;
} }
const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => { const Nav = ({
Icon,
label,
path,
keyPath,
disabled = false,
isMinimized = false,
badge,
}: NavProps) => {
return ( return (
<Link <Link
href={!disabled ? keyPath : ""} href={!disabled ? keyPath : ""}
className={clsx( className={clsx(
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out relative", "transition-all duration-300 ease-in-out relative",
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer", disabled
? "hover:bg-mti-gray-dim cursor-not-allowed"
: "hover:bg-mti-purple-light cursor-pointer",
path.startsWith(keyPath) && "bg-mti-purple-light text-white", path.startsWith(keyPath) && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]"
)}> )}
>
<Icon size={24} /> <Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
{!!badge && badge > 0 && ( {!!badge && badge > 0 && (
@@ -68,8 +83,9 @@ const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false
className={clsx( className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white", "bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0", isMinimized && "absolute right-0 top-0"
)}> )}
>
{badge} {badge}
</div> </div>
)} )}
@@ -84,23 +100,157 @@ export default function Sidebar({
focusMode = false, focusMode = false,
user, user,
onFocusLayerMouseEnter, onFocusLayerMouseEnter,
className className,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
const isAdmin = useMemo(() => ['developer', 'admin'].includes(user?.type), [user?.type]) const isAdmin = useMemo(
() => ["developer", "admin"].includes(user?.type),
[user?.type]
);
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
state.isSidebarMinimized,
state.toggleSidebarMinimized,
]);
const { totalAssignedTickets } = useTicketsListener(user.id);
const { permissions } = usePermissions(user.id); const { permissions } = usePermissions(user.id);
const entitiesAllowStatistics = useAllowedEntities(user, entities, "view_statistics") const entitiesAllowStatistics = useAllowedEntities(
const entitiesAllowPaymentRecord = useAllowedEntities(user, entities, "view_payment_record") user,
entities,
"view_statistics"
);
const entitiesAllowPaymentRecord = useAllowedEntities(
user,
entities,
"view_payment_record"
);
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [ const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level" user,
]) entities,
[
"generate_reading",
"generate_listening",
"generate_writing",
"generate_speaking",
"generate_level",
]
);
const sidebarPermissions = useMemo<{ [key: string]: boolean }>(() => {
if (user.type === "developer") {
return {
viewExams: true,
viewStats: true,
viewRecords: true,
viewTickets: true,
viewClassrooms: true,
viewSettings: true,
viewPaymentRecord: true,
viewGeneration: true,
viewApprovalWorkflows: true,
};
}
const sidebarPermissions: { [key: string]: boolean } = {
viewExams: false,
viewStats: false,
viewRecords: false,
viewTickets: false,
viewClassrooms: false,
viewSettings: false,
viewPaymentRecord: false,
viewGeneration: false,
viewApprovalWorkflows: false,
};
if (!user || !user?.type) return sidebarPermissions;
const neededPermissions = permissions.reduce((acc, curr) => {
if (
["viewExams", "viewRecords", "viewTickets"].includes(curr as string)
) {
acc.push(curr);
}
return acc;
}, [] as PermissionType[]);
if (
["student", "teacher", "developer"].includes(user.type) &&
neededPermissions.includes("viewExams")
) {
sidebarPermissions["viewExams"] = true;
}
if (
getTypesOfUser(["agent"]).includes(user.type) &&
(entitiesAllowStatistics.length > 0 ||
neededPermissions.includes("viewStats"))
) {
sidebarPermissions["viewStats"] = true;
}
if (
[
"admin",
"developer",
"teacher",
"corporate",
"mastercorporate",
].includes(user.type) &&
(entitiesAllowGeneration.length > 0 || isAdmin)
) {
sidebarPermissions["viewGeneration"] = true;
sidebarPermissions["viewApprovalWorkflows"] = true;
}
if (
getTypesOfUser(["agent"]).includes(user.type) &&
neededPermissions.includes("viewRecords")
) {
sidebarPermissions["viewRecords"] = true;
}
if (
["admin", "developer", "agent"].includes(user.type) &&
neededPermissions.includes("viewTickets")
) {
sidebarPermissions["viewTickets"] = true;
}
if (
[
"admin",
"mastercorporate",
"developer",
"corporate",
"teacher",
"student",
].includes(user.type)
) {
sidebarPermissions["viewClassrooms"] = true;
}
if (getTypesOfUser(["student", "agent"]).includes(user.type)) {
sidebarPermissions["viewSettings"] = true;
}
if (
["admin", "developer", "agent", "corporate", "mastercorporate"].includes(
user.type
) &&
entitiesAllowPaymentRecord.length > 0
) {
sidebarPermissions["viewPaymentRecord"] = true;
}
return sidebarPermissions;
}, [
entitiesAllowGeneration.length,
entitiesAllowPaymentRecord.length,
entitiesAllowStatistics.length,
isAdmin,
permissions,
user,
]);
const { totalAssignedTickets } = useTicketsListener(
user.id,
sidebarPermissions["viewTickets"]
);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
@@ -114,18 +264,40 @@ export default function Sidebar({
<section <section
className={clsx( className={clsx(
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8", "relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6", isMinimized ? "w-fit" : "-xl:w-20 w-1/6",
className, className
)}> )}
>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/dashboard" isMinimized={isMinimized} /> <Nav
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && ( disabled={disableNavigation}
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Practice" path={path} keyPath="/exam" isMinimized={isMinimized} /> Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/dashboard"
isMinimized={isMinimized}
/>
{sidebarPermissions["viewExams"] && (
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Practice"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"])) && entitiesAllowStatistics.length > 0 && ( {sidebarPermissions["viewStats"] && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> <Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, ["developer", "admin", "mastercorporate", "corporate", "teacher", "student"], permissions) && ( {sidebarPermissions["viewClassrooms"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsPeople} Icon={BsPeople}
@@ -135,13 +307,27 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> <Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} /> <Nav
disabled={disableNavigation}
Icon={CiDumbbell}
label="Training"
path={path}
keyPath="/training"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"]) && entitiesAllowPaymentRecord.length > 0 && ( {sidebarPermissions["viewPaymentRecords"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -151,7 +337,7 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && ( {sidebarPermissions["viewSettings"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsShieldFill} Icon={BsShieldFill}
@@ -161,7 +347,7 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && ( {sidebarPermissions["viewTickets"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsClipboardData} Icon={BsClipboardData}
@@ -172,8 +358,7 @@ export default function Sidebar({
badge={totalAssignedTickets} badge={totalAssignedTickets}
/> />
)} )}
{checkAccess(user, ["admin", "developer", "teacher", 'corporate', 'mastercorporate']) {sidebarPermissions["viewGeneration"] && (
&& (entitiesAllowGeneration.length > 0 || isAdmin) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCloudFill} Icon={BsCloudFill}
@@ -183,23 +368,76 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{sidebarPermissions["viewApprovalWorkflows"] && (
<Nav
disabled={disableNavigation}
Icon={GoWorkflow}
label="Approval Workflows"
path={path}
keyPath="/approval-workflows"
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 disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized /> <Nav
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized /> disabled={disableNavigation}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && ( Icon={MdSpaceDashboard}
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized /> label="Dashboard"
path={path}
keyPath="/"
isMinimized
/>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized
/>
{sidebarPermissions["viewStats"] && (
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized /> <Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {sidebarPermissions["viewRecords"] && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized /> <Nav
disabled={disableNavigation}
Icon={CiDumbbell}
label="Training"
path={path}
keyPath="/training"
isMinimized
/>
)} )}
{checkAccess(user, getTypesOfUser(["student"])) && ( {sidebarPermissions["viewSettings"] && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized /> <Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized
/>
)} )}
{entitiesAllowGeneration.length > 0 && ( {sidebarPermissions["viewGeneration"] && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCloudFill} Icon={BsCloudFill}
@@ -209,6 +447,16 @@ export default function Sidebar({
isMinimized isMinimized
/> />
)} )}
{sidebarPermissions["viewApprovalWorkflows"] && (
<Nav
disabled={disableNavigation}
Icon={GoWorkflow}
label="Approval Workflows"
path={path}
keyPath="/approval-workflows"
isMinimized
/>
)}
</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">
@@ -218,24 +466,36 @@ export default function Sidebar({
onClick={toggleMinimize} onClick={toggleMinimize}
className={clsx( className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)}> )}
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />} >
{!isMinimized && <span className="text-lg font-medium">Minimize</span>} {isMinimized ? (
<BsChevronBarRight size={24} />
) : (
<BsChevronBarLeft size={24} />
)}
{!isMinimized && (
<span className="text-lg font-medium">Minimize</span>
)}
</div> </div>
<div <div
role="button" role="button"
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",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)}> )}
>
<RiLogoutBoxFill size={24} /> <RiLogoutBoxFill size={24} />
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>} {!isMinimized && (
<span className="-xl:hidden text-lg font-medium">Log Out</span>
)}
</div> </div>
</div> </div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} {focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</section> </section>
); );
} }

View File

@@ -29,7 +29,7 @@ function QuestionSolutionArea({
</div> </div>
<div <div
className={clsx( className={clsx(
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2", "w-56 h-10 border self-center rounded-xl items-center justify-center flex gap-3 px-2",
!userSolution !userSolution
? "border-mti-gray-davy" ? "border-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString() : userSolution.option.toString() === question.solution.toString()

View File

@@ -0,0 +1,204 @@
[
{
"id": "kajhfakscbka-asacaca-acawesae",
"name": "English Exam 1st Quarter 2025",
"entityId": "64a92896-fa8c-4908-95f3-23ffe05560c5",
"modules": [
"reading",
"writing"
],
"requester": "ffdIipRyXTRmm10Sq2eg7P97rLB2",
"startDate": 1737712243906,
"status": "pending",
"steps": [
{
"stepType": "form-intake",
"stepNumber": 1,
"completed": true,
"completedBy": "5fZibjknlJdfIZVndlV2FIdamtn1",
"completedDate": 1737712243906,
"firstStep": true,
"assignees": [
"5fZibjknlJdfIZVndlV2FIdamtn1",
"50jqJuESQNX0Qas64B5JZBQTIiq1",
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
],
"comments": "This is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\n"
},
{
"stepType": "approval-by",
"stepNumber": 2,
"completed": true,
"completedBy": "50jqJuESQNX0Qas64B5JZBQTIiq1",
"completedDate": 1737712243906,
"assignees": [
"5fZibjknlJdfIZVndlV2FIdamtn1",
"50jqJuESQNX0Qas64B5JZBQTIiq1",
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 3,
"completed": false,
"assignees": [
"5fZibjknlJdfIZVndlV2FIdamtn1",
"50jqJuESQNX0Qas64B5JZBQTIiq1",
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 4,
"completed": false,
"assignees": [
"50jqJuESQNX0Qas64B5JZBQTIiq1"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 5,
"completed": false,
"finalStep": true,
"assignees": [
"50jqJuESQNX0Qas64B5JZBQTIiq1",
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
],
"comments": "This is a random comment"
}
]
},
{
"id": "aaaaaakscbka-asacaca-acawesae",
"name": "English Exam 2nd Quarter 2025",
"entityId": "64a92896-fa8c-4908-95f3-23ffe05560c5",
"modules": [
"reading",
"writing",
"level",
"speaking",
"listening"
],
"requester": "231c84b2-a65a-49a9-803c-c664d84b13e0",
"startDate": 1737712243906,
"status": "approved",
"steps": [
{
"stepType": "form-intake",
"stepNumber": 1,
"completed": true,
"completedBy": "fd5fce42-4bcc-4150-a143-b484e750b265",
"completedDate": 1737712243906,
"firstStep": true,
"assignees": [
"fd5fce42-4bcc-4150-a143-b484e750b265",
"231c84b2-a65a-49a9-803c-c664d84b13e0",
"c5fc1514-1a94-4f8c-a046-a62099097a50"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 2,
"completed": true,
"completedBy": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
"completedDate": 1737712243906,
"assignees": [
"fd5fce42-4bcc-4150-a143-b484e750b265",
"rTh9yz6Z1WOidHlVOSGInlpoxrk1",
"c5fc1514-1a94-4f8c-a046-a62099097a50"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 3,
"completed": true,
"completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
"completedDate": 1737712243906,
"assignees": [
"fd5fce42-4bcc-4150-a143-b484e750b265",
"231c84b2-a65a-49a9-803c-c664d84b13e0",
"c5fc1514-1a94-4f8c-a046-a62099097a50"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 4,
"completed": true,
"completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
"completedDate": 1737712243906,
"assignees": [
"fd5fce42-4bcc-4150-a143-b484e750b265"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 5,
"completed": true,
"completedBy": "c5fc1514-1a94-4f8c-a046-a62099097a50",
"completedDate": 1737712243906,
"finalStep": true,
"assignees": [
"rTh9yz6Z1WOidHlVOSGInlpoxrk1",
"c5fc1514-1a94-4f8c-a046-a62099097a50"
],
"comments": "This is a random comment"
}
]
},
{
"id": "bbbbkscbka-asacaca-acawesae",
"name": "English Exam 3rd Quarter 2025",
"entityId": "49ed2f0c-7d0d-46e4-9576-7cf19edc4980",
"modules": [
"reading"
],
"requester": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
"startDate": 1737712243906,
"status": "rejected",
"steps": [
{
"stepType": "form-intake",
"stepNumber": 1,
"completed": true,
"completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
"completedDate": 1737712243906,
"firstStep": true,
"assignees": [
"fd5fce42-4bcc-4150-a143-b484e750b265",
"231c84b2-a65a-49a9-803c-c664d84b13e0",
"c5fc1514-1a94-4f8c-a046-a62099097a50"
],
"comments": "This is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\n"
},
{
"stepType": "approval-by",
"stepNumber": 2,
"completed": true,
"completedBy": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
"completedDate": 1737712243906,
"assignees": [
"rTh9yz6Z1WOidHlVOSGInlpoxrk1",
"c5fc1514-1a94-4f8c-a046-a62099097a50"
],
"comments": "This is a random comment"
},
{
"stepType": "approval-by",
"stepNumber": 3,
"completed": false,
"finalStep": true,
"assignees": [
"rTh9yz6Z1WOidHlVOSGInlpoxrk1"
],
"comments": "This is a random comment"
}
]
}
]

View File

@@ -1,50 +1,80 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {useMemo, useState} from "react"; import { useMemo, useState } from "react";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {Stat, User} from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import {
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; BsArrowRepeat,
import {totalExamsByModule} from "@/utils/stats"; BsBook,
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {calculateAverageLevel} from "@/utils/score"; import { sortByModuleName } from "@/utils/moduleUtils";
import {sortByModuleName} from "@/utils/moduleUtils"; import { capitalize } from "lodash";
import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam"; import { Variant } from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions"; import useSessions, { Session } from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import moment from "moment"; import useStats from "../hooks/useStats";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void; onStart: (
modules: Module[],
avoidRepeated: boolean,
variant: Variant
) => void;
} }
export default function Selection({user, page, onStart}: Props) { export default function Selection({ user, page, onStart }: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id); const {
const {sessions, isLoading, reload} = useSessions(user.id); data: {
allStats = [],
moduleCount: { reading, listening, writing, speaking, level } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
},
},
} = useStats<{
allStats: Stat[];
moduleCount: Record<Module, number>;
}>(user?.id, !user?.id, "byModule");
const { sessions, isLoading, reload } = useSessions(user.id);
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module]
);
}; };
const isCompleteExam = useMemo(() => const isCompleteExam = useMemo(
["reading", "listening", "writing", "speaking"].every(m => selectedModules.includes(m as Module)), [selectedModules] () =>
) ["reading", "listening", "writing", "speaking"].every((m) =>
selectedModules.includes(m as Module)
),
[selectedModules]
);
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
dispatch({type: "SET_SESSION", payload: { session }}) dispatch({ type: "SET_SESSION", payload: { session } });
}; };
return ( return (
@@ -55,33 +85,43 @@ export default function Selection({user, page, onStart}: Props) {
user={user} user={user}
items={[ items={[
{ {
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />, icon: (
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
),
label: "Reading", label: "Reading",
value: totalExamsByModule(stats, "reading"), value: reading || 0,
tooltip: "The amount of reading exams performed.", tooltip: "The amount of reading exams performed.",
}, },
{ {
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />, icon: (
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
),
label: "Listening", label: "Listening",
value: totalExamsByModule(stats, "listening"), value: listening || 0,
tooltip: "The amount of listening exams performed.", tooltip: "The amount of listening exams performed.",
}, },
{ {
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />, icon: (
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
),
label: "Writing", label: "Writing",
value: totalExamsByModule(stats, "writing"), value: writing || 0,
tooltip: "The amount of writing exams performed.", tooltip: "The amount of writing exams performed.",
}, },
{ {
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />, icon: (
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
),
label: "Speaking", label: "Speaking",
value: totalExamsByModule(stats, "speaking"), value: speaking || 0,
tooltip: "The amount of speaking exams performed.", tooltip: "The amount of speaking exams performed.",
}, },
{ {
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />, icon: (
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level", label: "Level",
value: totalExamsByModule(stats, "level"), value: level || 0,
tooltip: "The amount of level exams performed.", tooltip: "The amount of level exams performed.",
}, },
]} ]}
@@ -93,23 +133,35 @@ export default function Selection({user, page, onStart}: Props) {
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
{page === "exercises" && ( {page === "exercises" && (
<> <>
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full In the realm of language acquisition, practice makes perfect,
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar and our exercises are the key to unlocking your full potential.
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully Dive into a world of interactive and engaging exercises that
designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific cater to diverse learning styles. From grammar drills that build
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence. a strong foundation to vocabulary challenges that broaden your
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language lexicon, our exercises are carefully designed to make learning
acquisition. Your linguistic adventure starts here! English both enjoyable and effective. Whether you&apos;re
looking to reinforce specific skills or embark on a holistic
language journey, our exercises are your companions in the
pursuit of excellence. Embrace the joy of learning as you
navigate through a variety of activities that cater to every
facet of language acquisition. Your linguistic adventure starts
here!
</> </>
)} )}
{page === "exams" && ( {page === "exams" && (
<> <>
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and Welcome to the heart of success on your English language
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate journey! Our exams are crafted with precision to assess and
your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a enhance your language skills. Each test is a passport to your
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of linguistic prowess, designed to challenge and elevate your
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a abilities. Whether you&apos;re a beginner or a seasoned learner,
destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language. our exams cater to all levels, providing a comprehensive
evaluation of your reading, writing, speaking, and listening
skills. Prepare to embark on a journey of self-discovery and
language mastery as you navigate through our thoughtfully
curated exams. Your success is not just a destination; it&apos;s
a testament to your dedication and our commitment to empowering
you with the English language.
</> </>
)} )}
</span> </span>
@@ -120,16 +172,24 @@ export default function Selection({user, page, onStart}: Props) {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div
onClick={reload} onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span> >
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} /> <span className="text-mti-black text-lg font-bold">
Unfinished Sessions
</span>
<BsArrowRepeat
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2"> <span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
{sessions {sessions.map((session) => (
.sort((a, b) => moment(b.date).diff(moment(a.date))) <SessionCard
.map((session) => ( session={session}
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} /> key={session.sessionId}
reload={reload}
loadSession={loadSession}
/>
))} ))}
</span> </span>
</section> </section>
@@ -137,107 +197,163 @@ export default function Selection({user, page, onStart}: Props) {
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8"> <section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsBook className="h-7 w-7 text-white" /> <BsBook className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Reading:</span> <span className="font-semibold">Reading:</span>
<p className="text-left text-xs"> <p className="text-left text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English. Expand your vocabulary, improve your reading comprehension and
improve your ability to interpret texts in English.
</p> </p>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && ( {!selectedModules.includes("reading") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("reading")) && ( {selectedModules.includes("reading") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsHeadphones className="h-7 w-7 text-white" /> <BsHeadphones className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Listening:</span> <span className="font-semibold">Listening:</span>
<p className="text-left text-xs"> <p className="text-left text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations. Improve your ability to follow conversations in English and your
ability to understand different accents and intonations.
</p> </p>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && ( {!selectedModules.includes("listening") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("listening")) && ( {selectedModules.includes("listening") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsPen className="h-7 w-7 text-white" /> <BsPen className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Writing:</span> <span className="font-semibold">Writing:</span>
<p className="text-left text-xs"> <p className="text-left text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays. Allow you to practice writing in a variety of formats, from simple
paragraphs to complex essays.
</p> </p>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && ( {!selectedModules.includes("writing") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("writing")) && ( {selectedModules.includes("writing") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsMegaphone className="h-7 w-7 text-white" /> <BsMegaphone className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Speaking:</span> <span className="font-semibold">Speaking:</span>
<p className="text-left text-xs"> <p className="text-left text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings. You&apos;ll have access to interactive dialogs, pronunciation
exercises and speech recordings.
</p> </p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( {!selectedModules.includes("speaking") &&
!selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("speaking")) && ( {selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div> </div>
<div <div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined} onClick={
selectedModules.length === 0 || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("level")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsClipboard className="h-7 w-7 text-white" /> <BsClipboard className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Level:</span> <span className="font-semibold">Level:</span>
<p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p> <p className="text-left text-xs">
{!selectedModules.includes("level") && selectedModules.length === 0 && ( You&apos;ll be able to test your english level with multiple
choice questions.
</p>
{!selectedModules.includes("level") &&
selectedModules.length === 0 && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("level")) && ( {selectedModules.includes("level") && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{!selectedModules.includes("level") && selectedModules.length > 0 && ( {!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)} )}
</div> </div>
@@ -246,37 +362,53 @@ export default function Selection({user, page, onStart}: Props) {
<div className="flex w-full flex-col items-center gap-3"> <div className="flex w-full flex-col items-center gap-3">
<div <div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm" className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}> onClick={() => setAvoidRepeatedExams((prev) => !prev)}
>
<input type="checkbox" className="hidden" /> <input type="checkbox" className="hidden" />
<div <div
className={clsx( className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white", "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ", avoidRepeatedExams && "!bg-mti-purple-light "
)}> )}
>
<BsCheck color="white" className="h-full w-full" /> <BsCheck color="white" className="h-full w-full" />
</div> </div>
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done."> <span
className="tooltip"
data-tip="If possible, the platform will choose exams not yet done."
>
Avoid Repeated Questions Avoid Repeated Questions
</span> </span>
</div> </div>
<div <div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm" className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}> onClick={() =>
setVariant((prev) => (prev === "full" ? "partial" : "full"))
}
>
<input type="checkbox" className="hidden" /> <input type="checkbox" className="hidden" />
<div <div
className={clsx( className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white", "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ", variant === "full" && "!bg-mti-purple-light "
)}> )}
>
<BsCheck color="white" className="h-full w-full" /> <BsCheck color="white" className="h-full w-full" />
</div> </div>
<span>Full length exams</span> <span>Full length exams</span>
</div> </div>
</div> </div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}> <div
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled> className="tooltip w-full"
data-tip={`Your screen size is too small to do ${page}`}
>
<Button
color="purple"
className="w-full max-w-xs px-12 md:hidden"
disabled
>
Start Exam Start Exam
</Button> </Button>
</div> </div>
@@ -284,7 +416,16 @@ export default function Selection({user, page, onStart}: Props) {
<Button <Button
color="green" color="green"
variant={isCompleteExam ? "solid" : "outline"} variant={isCompleteExam ? "solid" : "outline"}
onClick={() => isCompleteExam ? setSelectedModules([]) : setSelectedModules(["reading", "listening", "writing", "speaking"])} onClick={() =>
isCompleteExam
? setSelectedModules([])
: setSelectedModules([
"reading",
"listening",
"writing",
"speaking",
])
}
className="-md:hidden w-full max-w-xs px-12 md:self-end" className="-md:hidden w-full max-w-xs px-12 md:self-end"
> >
Complete Exam Complete Exam
@@ -294,12 +435,13 @@ export default function Selection({user, page, onStart}: Props) {
onStart( onStart(
selectedModules.sort(sortByModuleName), selectedModules.sort(sortByModuleName),
avoidRepeatedExams, avoidRepeatedExams,
variant, variant
) )
} }
color="purple" color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end" className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0}> disabled={selectedModules.length === 0}
>
Start Exam Start Exam
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,24 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
export default function useApprovalWorkflow(id: string) {
const [workflow, setWorkflow] = useState<ApprovalWorkflow>();
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
setIsLoading(true);
axios
.get<ApprovalWorkflow>(`/api/approval-workflows/${id}`)
.then((response) => setWorkflow(response.data))
.catch((error) => {
setIsError(true);
})
.finally(() => setIsLoading(false));
}, []);
useEffect(getData, [getData]);
return { workflow, isLoading, isError, reload: getData };
}

View File

@@ -0,0 +1,24 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
export default function useApprovalWorkflows() {
const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
setIsLoading(true);
axios
.get<ApprovalWorkflow[]>(`/api/approval-workflows`)
.then((response) => setWorkflows(response.data))
.catch((error) => {
setIsError(true);
})
.finally(() => setIsLoading(false));
}, []);
useEffect(getData, [getData]);
return { workflows, isLoading, isError, reload: getData };
}

View File

@@ -1,23 +1,22 @@
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Discount } from "@/interfaces/paypal";
import { Code, Group, User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
export default function useEntities() { export default function useEntities(shouldNot?: boolean) {
const [entities, setEntities] = useState<EntityWithRoles[]>([]); const [entities, setEntities] = useState<EntityWithRoles[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const getData = () => { const getData = useCallback(() => {
if (shouldNot) return;
setIsLoading(true); setIsLoading(true);
axios axios
.get<EntityWithRoles[]>("/api/entities?showRoles=true") .get<EntityWithRoles[]>("/api/entities?showRoles=true")
.then((response) => setEntities(response.data)) .then((response) => setEntities(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; }, [shouldNot]);
useEffect(getData, []); useEffect(getData, [getData])
return { entities, isLoading, isError, reload: getData }; return { entities, isLoading, isError, reload: getData };
} }

View File

@@ -1,6 +1,5 @@
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity"; import { WithLabeledEntities } from "@/interfaces/entity";
import { Discount } from "@/interfaces/paypal"; import { Type, User } from "@/interfaces/user";
import { Code, Group, Type, User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -12,7 +11,9 @@ export default function useEntitiesUsers(type?: Type) {
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<WithLabeledEntities<User>[]>(`/api/entities/users${type ? "?type=" + type : ""}`) .get<WithLabeledEntities<User>[]>(
`/api/entities/users${type ? "?type=" + type : ""}`
)
.then((response) => setUsers(response.data)) .then((response) => setUsers(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };

View File

@@ -1,95 +1,114 @@
import { UserSolution } from '@/interfaces/exam'; import { UserSolution } from "@/interfaces/exam";
import useExamStore from '@/stores/exam'; import useExamStore from "@/stores/exam";
import { StateFlags } from '@/stores/exam/types'; import axios from "axios";
import axios from 'axios'; import { useEffect, useRef } from "react";
import { SetStateAction, useEffect, useRef } from 'react'; import { useRouter } from "next/router";
type UseEvaluationPolling = (props: { const useEvaluationPolling = (sessionIds: string[], mode: "exam" | "records", userId: string) => {
pendingExercises: string[], const { setUserSolutions, userSolutions } = useExamStore();
setPendingExercises: React.Dispatch<SetStateAction<string[]>>, const pollingTimeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
}) => void; const router = useRouter();
const useEvaluationPolling: UseEvaluationPolling = ({ const poll = async (sessionId: string) => {
pendingExercises, try {
setPendingExercises, const { data: statusData } = await axios.get('/api/evaluate/status', {
}) => { params: { op: 'pending', userId, sessionId }
const { });
flags, sessionId, user,
userSolutions, evaluated,
setEvaluated, setFlags
} = useExamStore();
const pollingTimeoutRef = useRef<NodeJS.Timeout>(); if (!statusData.hasPendingEvaluation) {
let solutionsOrStats = userSolutions;
if (mode === "records") {
const res = await axios.get(`/api/stats/session/${sessionId}`)
solutionsOrStats = res.data;
}
const { data: completedSolutions } = await axios.post('/api/evaluate/fetchSolutions?op=session', {
sessionId,
userId,
stats: solutionsOrStats,
});
await axios.post('/api/stats/disabled', {
sessionId,
userId,
solutions: completedSolutions,
});
const timeout = pollingTimeoutsRef.current.get(sessionId);
if (timeout) clearTimeout(timeout);
pollingTimeoutsRef.current.delete(sessionId);
if (mode === "exam") {
const updatedSolutions = userSolutions.map(solution => {
const completed = completedSolutions.find(
(c: UserSolution) => c.exercise === solution.exercise
);
return completed || solution;
});
setUserSolutions(updatedSolutions);
} else {
router.reload();
}
} else {
if (pollingTimeoutsRef.current.has(sessionId)) {
clearTimeout(pollingTimeoutsRef.current.get(sessionId));
}
pollingTimeoutsRef.current.set(
sessionId,
setTimeout(() => poll(sessionId), 5000)
);
}
} catch (error) {
if (pollingTimeoutsRef.current.has(sessionId)) {
clearTimeout(pollingTimeoutsRef.current.get(sessionId));
}
pollingTimeoutsRef.current.set(
sessionId,
setTimeout(() => poll(sessionId), 5000)
);
}
};
useEffect(() => { useEffect(() => {
return () => { if (mode === "exam") {
if (pollingTimeoutRef.current) { const hasDisabledSolutions = userSolutions.some(s => s.isDisabled);
clearTimeout(pollingTimeoutRef.current);
if (hasDisabledSolutions && sessionIds.length > 0) {
poll(sessionIds[0]);
} else {
pollingTimeoutsRef.current.forEach((timeout) => {
clearTimeout(timeout);
});
pollingTimeoutsRef.current.clear();
} }
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, sessionIds, userSolutions]);
useEffect(() => {
if (mode === "records" && sessionIds.length > 0) {
sessionIds.forEach(sessionId => {
poll(sessionId);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, sessionIds]);
useEffect(() => {
const timeouts = pollingTimeoutsRef.current;
return () => {
timeouts.forEach((timeout) => {
clearTimeout(timeout);
});
timeouts.clear();
}; };
}, []); }, []);
useEffect(() => { return {
if (!flags.pendingEvaluation || pendingExercises.length === 0) { isPolling: pollingTimeoutsRef.current.size > 0
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
}
return;
}
const pollStatus = async () => {
try {
const { data } = await axios.get('/api/evaluate/status', {
params: {
sessionId,
userId: user,
exerciseIds: pendingExercises.join(',')
}
});
if (data.finishedExerciseIds.length > 0) {
const remainingExercises = pendingExercises.filter(
id => !data.finishedExerciseIds.includes(id)
);
setPendingExercises(remainingExercises);
if (remainingExercises.length === 0) {
const evaluatedData = await axios.post('/api/evaluate/fetchSolutions', {
sessionId,
userId: user,
userSolutions
});
const newEvaluations = evaluatedData.data.filter(
(newEval: UserSolution) =>
!evaluated.some(existingEval => existingEval.exercise === newEval.exercise)
);
setEvaluated([...evaluated, ...newEvaluations]);
setFlags({ pendingEvaluation: false });
return;
}
}
if (pendingExercises.length > 0) {
pollingTimeoutRef.current = setTimeout(pollStatus, 5000);
}
} catch (error) {
console.error('Evaluation polling error:', error);
pollingTimeoutRef.current = setTimeout(pollStatus, 5000);
}
}; };
pollStatus();
return () => {
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
}
};
});
}; };
export default useEvaluationPolling; export default useEvaluationPolling;

View File

@@ -3,13 +3,13 @@ import { useEffect, useState } from "react";
const endpoints: Record<string, string> = { const endpoints: Record<string, string> = {
stats: "/api/stats", stats: "/api/stats",
training: "/api/training" training: "/api/training",
}; };
export default function useFilterRecordsByUser<T extends any[]>( export default function useFilterRecordsByUser<T extends any[]>(
id?: string, id?: string,
shouldNotQuery?: boolean, shouldNotQuery?: boolean,
recordType: string = 'stats' recordType: string = "stats"
) { ) {
type ElementType = T extends (infer U)[] ? U : never; type ElementType = T extends (infer U)[] ? U : never;
@@ -19,7 +19,7 @@ export default function useFilterRecordsByUser<T extends any[]>(
const endpointURL = endpoints[recordType] || endpoints.stats; const endpointURL = endpoints[recordType] || endpoints.stats;
// CAUTION: This makes the assumption that the record enpoint has a /user/${id} endpoint // CAUTION: This makes the assumption that the record enpoint has a /user/${id} endpoint
const endpoint = !id ? endpointURL: `${endpointURL}/user/${id}`; const endpoint = !id ? endpointURL : `${endpointURL}/user/${id}`;
const getData = () => { const getData = () => {
if (shouldNotQuery) return; if (shouldNotQuery) return;
@@ -31,7 +31,7 @@ export default function useFilterRecordsByUser<T extends any[]>(
.get<T>(endpoint) .get<T>(endpoint)
.then((response) => { .then((response) => {
// CAUTION: This makes the assumption ElementType has a "user" field that contains the user id // CAUTION: This makes the assumption ElementType has a "user" field that contains the user id
setData(response.data.filter((x: ElementType) => (id ? (x as any).user === id : true)) as T); setData(response.data);
}) })
.catch(() => setIsError(true)) .catch(() => setIsError(true))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
@@ -46,6 +46,6 @@ export default function useFilterRecordsByUser<T extends any[]>(
data, data,
reload: getData, reload: getData,
isLoading, isLoading,
isError isError,
}; };
} }

View File

@@ -17,8 +17,7 @@ export default function usePermissions(user: string) {
.get<Permission[]>(`/api/permissions`) .get<Permission[]>(`/api/permissions`)
.then((response) => { .then((response) => {
const permissionTypes = response.data const permissionTypes = response.data
.filter((x) => !x.users.includes(user)) .reduce((acc, curr) => curr.users.includes(user)? acc : [...acc, curr.type], [] as PermissionType[]);
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
setPermissions(permissionTypes); setPermissions(permissionTypes);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));

42
src/hooks/useStats.tsx Normal file
View File

@@ -0,0 +1,42 @@
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
export default function useStats<T extends any>(
id?: string,
shouldNotQuery: boolean = !id,
queryType: string = "stats"
) {
type ElementType = T extends (infer U)[] ? U : never;
const [data, setData] = useState<T>({} as unknown as T);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
if (shouldNotQuery) return;
setIsLoading(true);
setIsError(false);
let endpoint = `/api/stats/user/${id}`;
if (queryType) endpoint += `?query=${queryType}`;
axios
.get<T>(endpoint)
.then((response) => {
console.log(response.data);
setData(response.data);
})
.catch(() => setIsError(true))
.finally(() => setIsLoading(false));
}, [id, shouldNotQuery, queryType]);
useEffect(() => {
getData();
}, [getData]);
return {
data,
reload: getData,
isLoading,
isError,
};
}

View File

@@ -1,22 +1,30 @@
import React from "react"; import { useState, useEffect, useCallback } from "react";
import useTickets from "./useTickets"; import axios from "axios";
const useTicketsListener = (userId?: string) => { const useTicketsListener = (userId?: string, canFetch?: boolean) => {
const { tickets, reload } = useTickets(); const [assignedTickets, setAssignedTickets] = useState([]);
React.useEffect(() => { const getData = useCallback(() => {
axios
.get("/api/tickets/assignedToUser")
.then((response) => setAssignedTickets(response.data));
}, []);
useEffect(() => {
if (!canFetch) return;
getData();
}, [canFetch, getData]);
useEffect(() => {
if (!canFetch) return;
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
reload(); getData();
}, 60 * 1000); }, 60 * 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [reload]); }, [assignedTickets, canFetch, getData]);
if (userId) { if (userId) {
const assignedTickets = tickets.filter(
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
);
return { return {
assignedTickets, assignedTickets,
totalAssignedTickets: assignedTickets.length, totalAssignedTickets: assignedTickets.length,

27
src/hooks/useUserData.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { useEffect, useState, useCallback } from "react";
import { User } from "../interfaces/user";
import axios from "axios";
export default function useUserData({
userId,
}: {
userId: string;
}) {
const [userData, setUserData] = useState<User | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
if (!userId ) return;
setIsLoading(true);
axios
.get(`/api/users/${userId}`)
.then((response) => setUserData(response.data))
.finally(() => setIsLoading(false))
.catch((error) => setIsError(true));
}, [userId]);
useEffect(getData, [getData]);
return { userData, isLoading, isError, reload: getData };
}

View File

@@ -0,0 +1,99 @@
import Axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { setupCache } from "axios-cache-interceptor";
import Option from "../interfaces/option";
const instance = Axios.create();
const axios = setupCache(instance);
export default function useUsersSelect(props?: {
type?: string;
size?: number;
orderBy?: string;
direction?: "asc" | "desc";
entities?: string[] | string;
}) {
const [inputValue, setInputValue] = useState("");
const [users, setUsers] = useState<Option[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const onScrollLoadMoreOptions = useCallback(() => {
if (users.length === total) return;
const params = new URLSearchParams();
if (!!props)
Object.keys(props).forEach((key) => {
if (props[key as keyof typeof props] !== undefined)
params.append(key, props[key as keyof typeof props]!.toString());
});
setIsLoading(true);
return axios
.get<{ users: Option[]; total: number }>(
`/api/users/search?value=${inputValue}&page=${
page + 1
}&${params.toString()}`,
{ headers: { page: "register" } }
)
.then((response) => {
setPage((curr) => curr + 1);
setTotal(response.data.total);
setUsers((curr) => [...curr, ...response.data.users]);
setIsLoading(false);
return response.data.users;
});
}, [inputValue, page, props, total, users.length]);
const loadOptions = useCallback(
async (inputValue: string,forced?:boolean) => {
let load = true;
setInputValue((currValue) => {
if (!forced&&currValue === inputValue) {
load = false;
return currValue;
}
return inputValue;
});
if (!load) return;
const params = new URLSearchParams();
if (!!props)
Object.keys(props).forEach((key) => {
if (props[key as keyof typeof props] !== undefined)
params.append(key, props[key as keyof typeof props]!.toString());
});
setIsLoading(true);
setPage(0);
return axios
.get<{ users: Option[]; total: number }>(
`/api/users/search?value=${inputValue}&page=0&${params.toString()}`,
{ headers: { page: "register" } }
)
.then((response) => {
setTotal(response.data.total);
setUsers(response.data.users);
setIsLoading(false);
return response.data.users;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[props?.entities, props?.type, props?.size, props?.orderBy, props?.direction]
);
useEffect(() => {
loadOptions("",true);
}, [loadOptions]);
return {
users,
total,
isLoading,
isError,
onScrollLoadMoreOptions,
loadOptions,
inputValue,
};
}

View File

@@ -0,0 +1,71 @@
import { ObjectId } from "mongodb";
import { Module } from ".";
import { Type, User, userTypeLabels, userTypeLabelsShort } from "./user";
export interface ApprovalWorkflow {
_id?: ObjectId,
name: string,
entityId: string,
requester: User["id"],
startDate: number,
modules: Module[],
examId?: string,
status: ApprovalWorkflowStatus,
steps: WorkflowStep[],
}
export interface EditableApprovalWorkflow extends Omit<ApprovalWorkflow, "_id" | "steps"> {
id: string,
steps: EditableWorkflowStep[],
}
export type StepType = "form-intake" | "approval-by";
export const StepTypeLabel: Record<StepType, string> = {
"form-intake": "Form Intake",
"approval-by": "Approval",
};
export interface WorkflowStep {
stepType: StepType,
stepNumber: number,
completed: boolean,
rejected?: boolean,
completedBy?: User["id"],
completedDate?: number,
assignees: (User["id"])[];
firstStep?: boolean,
finalStep?: boolean,
selected?: boolean,
comments?: string,
onClick?: React.MouseEventHandler<HTMLDivElement>
}
export interface EditableWorkflowStep {
key: number,
stepType: StepType,
stepNumber: number,
completed: boolean,
rejected?: boolean,
completedBy?: User["id"],
completedDate?: number,
assignees: (User["id"] | null | undefined)[]; // bit of an hack, but allowing null or undefined values allows us to match one to one the select input components with the assignees array. And since select inputs allow undefined or null values, it is allowed here too, but must validate required input before form submission
firstStep: boolean,
finalStep?: boolean,
onDelete?: () => void;
}
export function getUserTypeLabel(type: Type | undefined): string {
if (type) return userTypeLabels[type];
return '';
}
export function getUserTypeLabelShort(type: Type | undefined): string {
if (type) return userTypeLabelsShort[type];
return '';
}
export type ApprovalWorkflowStatus = "approved" | "pending" | "rejected";
export const ApprovalWorkflowStatusLabel: Record<ApprovalWorkflowStatus, string> = {
approved: "Approved",
pending: "Pending",
rejected: "Rejected",
};

View File

@@ -1,4 +1,11 @@
export type Module = "reading" | "listening" | "writing" | "speaking" | "level"; export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
export const ModuleTypeLabels: Record<Module, string> = {
reading: "Reading",
listening: "Listening",
writing: "Writing",
speaking: "Speaking",
level: "Level",
};
export interface Step { export interface Step {
min: number; min: number;

View File

@@ -37,3 +37,5 @@ export interface Assignment {
} }
export type AssignmentWithCorporateId = Assignment & { corporateId: string }; export type AssignmentWithCorporateId = Assignment & { corporateId: string };
export type AssignmentWithHasResults = Assignment & { hasResults: boolean };

View File

@@ -170,4 +170,24 @@ export interface Code {
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate"; export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
export const userTypeLabels: Record<Type, string> = {
student: "Student",
teacher: "Teacher",
corporate: "Corporate",
admin: "Admin",
developer: "Developer",
agent: "Agent",
mastercorporate: "Master Corporate",
};
export const userTypeLabelsShort: Record<Type, string> = {
student: "",
teacher: "Prof.",
corporate: "Dir.",
admin: "Admin",
developer: "Dev.",
agent: "Agent",
mastercorporate: "Dir.",
};
export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T; export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T;

View File

@@ -5,13 +5,23 @@ import Separator from "@/components/Low/Separator";
import { Grading, Step } from "@/interfaces"; import { Grading, Step } from "@/interfaces";
import { Entity } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading"; import {
import { mapBy } from "@/utils"; CEFR_STEPS,
GENERAL_STEPS,
IELTS_STEPS,
TOFEL_STEPS,
} from "@/resources/grading";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { Divider } from "primereact/divider"; import {
import { useEffect, useState } from "react"; Dispatch,
memo,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { BsPlusCircle, BsTrash } from "react-icons/bs"; import { BsPlusCircle, BsTrash } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -27,30 +37,149 @@ const areStepsOverlapped = (steps: Step[]) => {
return false; return false;
}; };
interface RowProps {
min: number;
max: number;
index: number;
label: string;
isLast: boolean;
isLoading: boolean;
setSteps: Dispatch<SetStateAction<Step[]>>;
addRow: (index: number) => void;
}
function GradingRow({
min,
max,
label,
index,
isLoading,
isLast,
setSteps,
addRow,
}: RowProps) {
const onChangeMin = useCallback(
(e: string) => {
setSteps((prev) =>
prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x))
);
},
[index, setSteps]
);
const onChangeMax = useCallback(
(e: string) => {
setSteps((prev) =>
prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x))
);
},
[index, setSteps]
);
const onChangeLabel = useCallback(
(e: string) => {
setSteps((prev) =>
prev.map((x, i) => (i === index ? { ...x, label: e } : x))
);
},
[index, setSteps]
);
const onAddRow = useCallback(() => addRow(index), [addRow, index]);
const removeRow = useCallback(
() => setSteps((prev) => prev.filter((_, i) => i !== index)),
[index, setSteps]
);
return (
<>
<div className="flex items-center gap-4">
<div className="grid grid-cols-3 gap-4 w-full">
<Input
label="Min. Percentage"
value={min}
type="number"
disabled={index === 0 || isLoading}
onChange={onChangeMin}
name="min"
/>
<Input
label="Grade"
value={label}
type="text"
disabled={isLoading}
onChange={onChangeLabel}
name="min"
/>
<Input
label="Max. Percentage"
value={max}
type="number"
disabled={isLast || isLoading}
onChange={onChangeMax}
name="max"
/>
</div>
{index !== 0 && !isLast && (
<button
disabled={isLoading}
className="pt-9 text-xl group"
onClick={removeRow}
>
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
<BsTrash />
</div>
</button>
)}
</div>
{!isLast && (
<Button
className="w-full flex items-center justify-center"
disabled={isLoading}
onClick={onAddRow}
>
<BsPlusCircle />
</Button>
)}
</>
);
}
const GradingRowMemo = memo(GradingRow);
interface Props { interface Props {
user: User; user: User;
entitiesGrading: Grading[]; entitiesGrading: Grading[];
entities: Entity[] entities: Entity[];
mutate: () => void mutate: () => void;
} }
export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) { export default function CorporateGradingSystem({
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined) user,
entitiesGrading = [],
entities = [],
mutate,
}: Props) {
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [steps, setSteps] = useState<Step[]>([]); const [steps, setSteps] = useState<Step[]>([]);
const [otherEntities, setOtherEntities] = useState<string[]>([]) const [otherEntities, setOtherEntities] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
if (entity) { if (entity) {
const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps const entitySteps = entitiesGrading.find(
setSteps(entitySteps || []) (e) => e.entity === entity
)!.steps;
setSteps(entitySteps || []);
} }
}, [entitiesGrading, entity]) }, [entitiesGrading, entity]);
const saveGradingSystem = () => { const saveGradingSystem = () => {
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold."); if (!steps.every((x) => x.min < x.max))
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps."); return toast.error(
"One of your steps has a minimum threshold inferior to its superior threshold."
);
if (areStepsOverlapped(steps))
return toast.error("There seems to be an overlap in one of your steps.");
if ( if (
steps.reduce((acc, curr) => { steps.reduce((acc, curr) => {
return acc - (curr.max - curr.min + 1); return acc - (curr.max - curr.min + 1);
@@ -68,8 +197,12 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
}; };
const applyToOtherEntities = () => { const applyToOtherEntities = () => {
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold."); if (!steps.every((x) => x.min < x.max))
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps."); return toast.error(
"One of your steps has a minimum threshold inferior to its superior threshold."
);
if (areStepsOverlapped(steps))
return toast.error("There seems to be an overlap in one of your steps.");
if ( if (
steps.reduce((acc, curr) => { steps.reduce((acc, curr) => {
return acc - (curr.max - curr.min + 1); return acc - (curr.max - curr.min + 1);
@@ -77,24 +210,51 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
) )
return toast.error("There seems to be an open interval in your steps."); return toast.error("There seems to be an open interval in your steps.");
if (otherEntities.length === 0) return toast.error("Select at least one entity") if (otherEntities.length === 0)
return toast.error("Select at least one entity");
setIsLoading(true); setIsLoading(true);
axios axios
.post("/api/grading/multiple", { user: user.id, entities: otherEntities, steps }) .post("/api/grading/multiple", {
user: user.id,
entities: otherEntities,
steps,
})
.then(() => toast.success("Your grading system has been saved!")) .then(() => toast.success("Your grading system has been saved!"))
.then(mutate) .then(mutate)
.catch(() => toast.error("Something went wrong, please try again later")) .catch(() => toast.error("Something went wrong, please try again later"))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const addRow = useCallback((index: number) => {
setSteps((prev) => {
const item = {
min: prev[index === 0 ? 0 : index - 1].max + 1,
max: prev[index + 1].min - 1,
label: "",
};
return [
...prev.slice(0, index + 1),
item,
...prev.slice(index + 1, prev.length),
];
});
}, []);
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">Grading System</label> <label className="font-normal text-base text-mti-gray-dim">
Grading System
</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"])}
@@ -104,20 +264,33 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
{entities.length > 1 && ( {entities.length > 1 && (
<> <>
<Separator /> <Separator />
<label className="font-normal text-base text-mti-gray-dim">Apply this grading system to other entities</label> <label className="font-normal text-base text-mti-gray-dim">
Apply this grading system to other entities
</label>
<Select <Select
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))} onChange={(e) =>
!e
? setOtherEntities([])
: setOtherEntities(e.map((o) => o.value!))
}
isMulti isMulti
/> />
<Button onClick={applyToOtherEntities} isLoading={isLoading} disabled={isLoading || otherEntities.length === 0} variant="outline"> <Button
onClick={applyToOtherEntities}
isLoading={isLoading}
disabled={isLoading || otherEntities.length === 0}
variant="outline"
>
Apply to {otherEntities.length} other entities Apply to {otherEntities.length} other entities
</Button> </Button>
<Separator /> <Separator />
</> </>
)} )}
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label> <label className="font-normal text-base text-mti-gray-dim">
Preset Systems
</label>
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}> <Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
CEFR CEFR
@@ -134,61 +307,25 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
</div> </div>
{steps.map((step, index) => ( {steps.map((step, index) => (
<> <GradingRowMemo
<div className="flex items-center gap-4"> key={index}
<div className="grid grid-cols-3 gap-4 w-full" key={step.min}> min={step.min}
<Input max={step.max}
label="Min. Percentage" label={step.label}
value={step.min} index={index}
type="number" isLoading={isLoading}
disabled={index === 0 || isLoading} isLast={index === steps.length - 1}
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x)))} setSteps={setSteps}
name="min" addRow={addRow}
/> />
<Input
label="Grade"
value={step.label}
type="text"
disabled={isLoading}
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, label: e } : x)))}
name="min"
/>
<Input
label="Max. Percentage"
value={step.max}
type="number"
disabled={index === steps.length - 1 || isLoading}
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x)))}
name="max"
/>
</div>
{index !== 0 && index !== steps.length - 1 && (
<button
disabled={isLoading}
className="pt-9 text-xl group"
onClick={() => setSteps((prev) => prev.filter((_, i) => i !== index))}>
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
<BsTrash />
</div>
</button>
)}
</div>
{index < steps.length - 1 && (
<Button
className="w-full flex items-center justify-center"
disabled={isLoading}
onClick={() => {
const item = { min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: "" };
setSteps((prev) => [...prev.slice(0, index + 1), item, ...prev.slice(index + 1, steps.length)]);
}}>
<BsPlusCircle />
</Button>
)}
</>
))} ))}
<Button onClick={saveGradingSystem} isLoading={isLoading} disabled={isLoading} className="mt-8"> <Button
onClick={saveGradingSystem}
isLoading={isLoading}
disabled={isLoading}
className="mt-8"
>
Save Grading System Save Grading System
</Button> </Button>
</div> </div>

View File

@@ -6,7 +6,12 @@ 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 { useEffect, useMemo, useState } from "react";
import { BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsTrash } from "react-icons/bs"; import {
BsCheck,
BsCheckCircle,
BsFillExclamationOctagonFill,
BsTrash,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countries, TCountries } from "countries-list"; import { countries, TCountries } from "countries-list";
import countryCodes from "country-codes-list"; import countryCodes from "country-codes-list";
@@ -24,7 +29,6 @@ import { WithLabeledEntities } from "@/interfaces/entity";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
import useEntities from "@/hooks/useEntities"; import useEntities from "@/hooks/useEntities";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { findAllowedEntities } from "@/utils/permissions";
const columnHelper = createColumnHelper<WithLabeledEntities<User>>(); const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
const searchFields = [["name"], ["email"], ["entities", ""]]; const searchFields = [["name"], ["email"], ["entities", ""]];
@@ -40,31 +44,87 @@ export default function UserList({
type?: Type; type?: Type;
renderHeader?: (total: number) => JSX.Element; renderHeader?: (total: number) => JSX.Element;
}) { }) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false); const [showDemographicInformation, setShowDemographicInformation] =
useState(false);
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const { users, reload } = useEntitiesUsers(type) const { users, isLoading, reload } = useEntitiesUsers(type);
const { entities } = useEntities() const { entities } = useEntities();
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type]) const isAdmin = useMemo(
() => ["admin", "developer"].includes(user?.type),
[user?.type]
);
const entitiesViewStudents = useAllowedEntities(user, entities, "view_students") const entitiesViewStudents = useAllowedEntities(
const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students") user,
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students") entities,
"view_students"
);
const entitiesEditStudents = useAllowedEntities(
user,
entities,
"edit_students"
);
const entitiesDeleteStudents = useAllowedEntities(
user,
entities,
"delete_students"
);
const entitiesViewTeachers = useAllowedEntities(user, entities, "view_teachers") const entitiesViewTeachers = useAllowedEntities(
const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers") user,
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers") entities,
"view_teachers"
);
const entitiesEditTeachers = useAllowedEntities(
user,
entities,
"edit_teachers"
);
const entitiesDeleteTeachers = useAllowedEntities(
user,
entities,
"delete_teachers"
);
const entitiesViewCorporates = useAllowedEntities(user, entities, "view_corporates") const entitiesViewCorporates = useAllowedEntities(
const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates") user,
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates") entities,
"view_corporates"
);
const entitiesEditCorporates = useAllowedEntities(
user,
entities,
"edit_corporates"
);
const entitiesDeleteCorporates = useAllowedEntities(
user,
entities,
"delete_corporates"
);
const entitiesViewMasterCorporates = useAllowedEntities(user, entities, "view_mastercorporates") const entitiesViewMasterCorporates = useAllowedEntities(
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates") user,
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates") entities,
"view_mastercorporates"
);
const entitiesEditMasterCorporates = useAllowedEntities(
user,
entities,
"edit_mastercorporates"
);
const entitiesDeleteMasterCorporates = useAllowedEntities(
user,
entities,
"delete_mastercorporates"
);
const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list") const entitiesDownloadUsers = useAllowedEntities(
user,
entities,
"download_user_list"
);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -73,38 +133,70 @@ export default function UserList({
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through"; if (today.isAfter(momentDate))
return "!text-mti-red-light font-bold line-through";
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light"; if (today.add(2, "weeks").isAfter(momentDate))
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light"; return "!text-mti-rose-light";
if (today.add(1, "months").isAfter(momentDate))
return "!text-mti-orange-light";
}; };
const allowedUsers = useMemo(() => users.filter((u) => { const allowedUsers = useMemo(
if (isAdmin) return true () =>
if (u.id === user?.id) return false users.filter((u) => {
if (isAdmin) return true;
if (u.id === user?.id) return false;
switch (u.type) { switch (u.type) {
case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id)) case "student":
case "teacher": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewTeachers, 'id').includes(id)) return mapBy(u.entities || [], "id").some((id) =>
case 'corporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewCorporates, 'id').includes(id)) mapBy(entitiesViewStudents, "id").includes(id)
case 'mastercorporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewMasterCorporates, 'id').includes(id)) );
default: return false case "teacher":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewTeachers, "id").includes(id)
);
case "corporate":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewCorporates, "id").includes(id)
);
case "mastercorporate":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewMasterCorporates, "id").includes(id)
);
default:
return false;
} }
}) }),
, [entitiesViewCorporates, entitiesViewMasterCorporates, entitiesViewStudents, entitiesViewTeachers, isAdmin, user?.id, users]) [
entitiesViewCorporates,
entitiesViewMasterCorporates,
entitiesViewStudents,
entitiesViewTeachers,
isAdmin,
user?.id,
users,
]
);
const displayUsers = useMemo(() => const displayUsers = useMemo(
filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers, () =>
[filters, allowedUsers]) filters.length > 0
? filters.reduce((d, f) => d.filter(f), allowedUsers)
: allowedUsers,
[filters, allowedUsers]
);
const deleteAccount = (user: User) => { const deleteAccount = (user: User) => {
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return; if (!confirm(`Are you sure you want to delete ${user.name}'s account?`))
return;
axios axios
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`) .delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
.then(() => { .then(() => {
toast.success("User deleted successfully!"); toast.success("User deleted successfully!");
reload() reload();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "delete-error" }); toast.error("Something went wrong!", { toastId: "delete-error" });
@@ -130,8 +222,11 @@ export default function UserList({
const toggleDisableAccount = (user: User) => { const toggleDisableAccount = (user: User) => {
if ( if (
!confirm( !confirm(
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name `Are you sure you want to ${
}'s account? This change is usually related to their payment state.`, user.status === "disabled" ? "enable" : "disable"
} ${
user.name
}'s account? This change is usually related to their payment state.`
) )
) )
return; return;
@@ -142,7 +237,11 @@ export default function UserList({
status: user.status === "disabled" ? "active" : "disabled", status: user.status === "disabled" ? "active" : "disabled",
}) })
.then(() => { .then(() => {
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`); toast.success(
`User ${
user.status === "disabled" ? "enabled" : "disabled"
} successfully!`
);
reload(); reload();
}) })
.catch(() => { .catch(() => {
@@ -151,45 +250,60 @@ export default function UserList({
}; };
const getEditPermission = (type: Type) => { const getEditPermission = (type: Type) => {
if (type === "student") return entitiesEditStudents if (type === "student") return entitiesEditStudents;
if (type === "teacher") return entitiesEditTeachers if (type === "teacher") return entitiesEditTeachers;
if (type === "corporate") return entitiesEditCorporates if (type === "corporate") return entitiesEditCorporates;
if (type === "mastercorporate") return entitiesEditMasterCorporates if (type === "mastercorporate") return entitiesEditMasterCorporates;
return [] return [];
} };
const getDeletePermission = (type: Type) => { const getDeletePermission = (type: Type) => {
if (type === "student") return entitiesDeleteStudents if (type === "student") return entitiesDeleteStudents;
if (type === "teacher") return entitiesDeleteTeachers if (type === "teacher") return entitiesDeleteTeachers;
if (type === "corporate") return entitiesDeleteCorporates if (type === "corporate") return entitiesDeleteCorporates;
if (type === "mastercorporate") return entitiesDeleteMasterCorporates if (type === "mastercorporate") return entitiesDeleteMasterCorporates;
return [] return [];
} };
const canEditUser = (u: User) => const canEditUser = (u: User) =>
isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id)) isAdmin ||
u.entities.some((e) =>
mapBy(getEditPermission(u.type), "id").includes(e.id)
);
const canDeleteUser = (u: User) => const canDeleteUser = (u: User) =>
isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id)) isAdmin ||
u.entities.some((e) =>
mapBy(getDeletePermission(u.type), "id").includes(e.id)
);
const actionColumn = ({ row }: { row: { original: User } }) => { const actionColumn = ({ row }: { row: { original: User } }) => {
const canEdit = canEditUser(row.original) const canEdit = canEditUser(row.original);
const canDelete = canDeleteUser(row.original) const canDelete = canDeleteUser(row.original);
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{!row.original.isVerified && canEdit && ( {!row.original.isVerified && canEdit && (
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}> <div
data-tip="Verify User"
className="cursor-pointer tooltip"
onClick={() => verifyAccount(row.original)}
>
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
{canEdit && ( {canEdit && (
<div <div
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"} data-tip={
row.original.status === "disabled"
? "Enable User"
: "Disable User"
}
className="cursor-pointer tooltip" className="cursor-pointer tooltip"
onClick={() => toggleDisableAccount(row.original)}> onClick={() => toggleDisableAccount(row.original)}
>
{row.original.status === "disabled" ? ( {row.original.status === "disabled" ? (
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
) : ( ) : (
@@ -198,7 +312,11 @@ export default function UserList({
</div> </div>
)} )}
{canDelete && ( {canDelete && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}> <div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteAccount(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>
)} )}
@@ -213,11 +331,12 @@ export default function UserList({
<div <div
className={clsx( className={clsx(
canEditUser(row.original) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null canEditUser(row.original) ? setSelectedUser(row.original) : null
}> }
>
{getValue()} {getValue()}
</div> </div>
), ),
@@ -226,8 +345,14 @@ export default function UserList({
header: "Country", header: "Country",
cell: (info) => cell: (info) =>
info.getValue() info.getValue()
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name ? `${
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})` countryCodes.findOne("countryCode" as any, info.getValue())?.flag
} ${
countries[info.getValue() as unknown as keyof TCountries]?.name
} (+${
countryCodes.findOne("countryCode" as any, info.getValue())
?.countryCallingCode
})`
: "N/A", : "N/A",
}), }),
columnHelper.accessor("demographicInformation.phone", { columnHelper.accessor("demographicInformation.phone", {
@@ -237,17 +362,25 @@ export default function UserList({
}), }),
columnHelper.accessor( columnHelper.accessor(
(x) => (x) =>
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment, x.type === "corporate" || x.type === "mastercorporate"
? x.demographicInformation?.position
: x.demographicInformation?.employment,
{ {
id: "employment", id: "employment",
header: "Employment", header: "Employment",
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A", cell: (info) =>
(info.row.original.type === "corporate"
? info.getValue()
: capitalize(info.getValue())) || "N/A",
enableSorting: true, enableSorting: true,
}, }
), ),
columnHelper.accessor("lastLogin", { columnHelper.accessor("lastLogin", {
header: "Last Login", header: "Last Login",
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"), cell: (info) =>
!!info.getValue()
? moment(info.getValue()).format("YYYY-MM-DD HH:mm")
: "N/A",
}), }),
columnHelper.accessor("demographicInformation.gender", { columnHelper.accessor("demographicInformation.gender", {
header: "Gender", header: "Gender",
@@ -256,13 +389,16 @@ export default function UserList({
}), }),
{ {
header: ( header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}> <span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch Switch
</span> </span>
), ),
id: "actions", id: "actions",
cell: actionColumn, cell: actionColumn,
sortable: false sortable: false,
}, },
]; ];
@@ -273,11 +409,12 @@ export default function UserList({
<div <div
className={clsx( className={clsx(
canEditUser(row.original) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null canEditUser(row.original) ? setSelectedUser(row.original) : null
}> }
>
{getValue()} {getValue()}
</div> </div>
), ),
@@ -288,9 +425,12 @@ export default function UserList({
<div <div
className={clsx( className={clsx(
canEditUser(row.original) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => (canEditUser(row.original) ? setSelectedUser(row.original) : null)}> onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}
>
{getValue()} {getValue()}
</div> </div>
), ),
@@ -305,13 +445,21 @@ export default function UserList({
}), }),
columnHelper.accessor("entities", { columnHelper.accessor("entities", {
header: "Entities", header: "Entities",
cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '), cell: ({ getValue }) => mapBy(getValue(), "label").join(", "),
}), }),
columnHelper.accessor("subscriptionExpirationDate", { columnHelper.accessor("subscriptionExpirationDate", {
header: "Expiration", header: "Expiration",
cell: (info) => ( cell: (info) => (
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}> <span
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")} className={clsx(
info.getValue()
? expirationDateColor(moment(info.getValue()).toDate())
: ""
)}
>
{!info.getValue()
? "No expiry date"
: moment(info.getValue()).format("DD/MM/YYYY")}
</span> </span>
), ),
}), }),
@@ -323,8 +471,9 @@ export default function UserList({
className={clsx( className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white", "w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
info.getValue() && "!bg-mti-purple-light ", info.getValue() && "!bg-mti-purple-light "
)}> )}
>
<BsCheck color="white" className="w-full h-full" /> <BsCheck color="white" className="w-full h-full" />
</div> </div>
</div> </div>
@@ -332,20 +481,28 @@ export default function UserList({
}), }),
{ {
header: ( header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}> <span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch Switch
</span> </span>
), ),
id: "actions", id: "actions",
cell: actionColumn, cell: actionColumn,
sortable: false sortable: false,
}, },
]; ];
const downloadExcel = (rows: WithLabeledEntities<User>[]) => { const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.") if (entitiesDownloadUsers.length === 0)
return toast.error("You are not allowed to download the user list.");
const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e))) const allowedRows = rows.filter((r) =>
mapBy(r.entities, "id").some((e) =>
mapBy(entitiesDownloadUsers, "id").includes(e)
)
);
const csv = exportListToExcel(allowedRows); const csv = exportListToExcel(allowedRows);
const element = document.createElement("a"); const element = document.createElement("a");
@@ -359,10 +516,15 @@ export default function UserList({
const viewStudentFilter = (x: User) => x.type === "student"; const viewStudentFilter = (x: User) => x.type === "student";
const viewTeacherFilter = (x: User) => x.type === "teacher"; const viewTeacherFilter = (x: User) => x.type === "teacher";
const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id)); const belongsToAdminFilter = (x: User) =>
x.entities.some(({ id }) =>
mapBy(selectedUser?.entities || [], "id").includes(id)
);
const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x); const viewStudentFilterBelongsToAdmin = (x: User) =>
const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x); viewStudentFilter(x) && belongsToAdminFilter(x);
const viewTeacherFilterBelongsToAdmin = (x: User) =>
viewTeacherFilter(x) && belongsToAdminFilter(x);
const renderUserCard = (selectedUser: User) => { const renderUserCard = (selectedUser: User) => {
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
@@ -373,7 +535,9 @@ export default function UserList({
maxUserAmount={0} maxUserAmount={0}
loggedInUser={user} loggedInUser={user}
onViewStudents={ onViewStudents={
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0 (selectedUser.type === "corporate" ||
selectedUser.type === "teacher") &&
studentsFromAdmin.length > 0
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-students", id: "view-students",
@@ -389,7 +553,9 @@ export default function UserList({
: undefined : undefined
} }
onViewTeachers={ onViewTeachers={
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0 (selectedUser.type === "corporate" ||
selectedUser.type === "student") &&
teachersFromAdmin.length > 0
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-teachers", id: "view-teachers",
@@ -413,7 +579,7 @@ export default function UserList({
}); });
appendUserFilters({ appendUserFilters({
id: "belongs-to-admin", id: "belongs-to-admin",
filter: belongsToAdminFilter filter: belongsToAdminFilter,
}); });
router.push("/users"); router.push("/users");
@@ -434,14 +600,24 @@ export default function UserList({
<> <>
{renderHeader && renderHeader(displayUsers.length)} {renderHeader && renderHeader(displayUsers.length)}
<div className="w-full"> <div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}> <Modal
isOpen={!!selectedUser}
onClose={() => setSelectedUser(undefined)}
>
{selectedUser && renderUserCard(selectedUser)} {selectedUser && renderUserCard(selectedUser)}
</Modal> </Modal>
<Table<WithLabeledEntities<User>> <Table<WithLabeledEntities<User>>
data={displayUsers} data={displayUsers}
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any} columns={
(!showDemographicInformation
? defaultColumns
: demographicColumns) as any
}
searchFields={searchFields} searchFields={searchFields}
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined} onDownload={
entitiesDownloadUsers.length > 0 ? downloadExcel : undefined
}
isLoading={isLoading}
/> />
</div> </div>
</> </>

View File

@@ -1,9 +1,9 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import Layout from "@/components/High/Layout"; import { LayoutContext } from "@/components/High/Layout";
import Finish from "@/exams/Finish"; import Finish from "@/exams/Finish";
import Level from "@/exams/Level"; import Level from "@/exams/Level";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
@@ -11,9 +11,12 @@ import Reading from "@/exams/Reading";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import { Exam, LevelExam, UserSolution, Variant, WritingExam } from "@/interfaces/exam"; import { Exam, LevelExam, Variant } from "@/interfaces/exam";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; import {
evaluateSpeakingAnswer,
evaluateWritingAnswer,
} from "@/utils/evaluation";
import { getExam } from "@/utils/exams"; import { getExam } from "@/utils/exams";
import axios from "axios"; import axios from "axios";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -26,26 +29,39 @@ import useEvaluationPolling from "@/hooks/useEvaluationPolling";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
user: User; user: User;
destination?: string destination?: string;
hideSidebar?: boolean hideSidebar?: boolean;
} }
export default function ExamPage({ page, user, destination = "/", hideSidebar = false }: Props) { export default function ExamPage({
page,
user,
destination = "/",
hideSidebar = false,
}: Props) {
const router = useRouter(); const router = useRouter();
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [pendingExercises, setPendingExercises] = useState<string[]>([]); const [moduleLock, setModuleLock] = useState(false);
const { const {
exam, setExam, exam,
setExam,
exams, exams,
sessionId, setSessionId, setPartIndex, sessionId,
moduleIndex, setModuleIndex, setSessionId,
setQuestionIndex, setExerciseIndex, setPartIndex,
userSolutions, setUserSolutions, moduleIndex,
showSolutions, setShowSolutions, setModuleIndex,
selectedModules, setSelectedModules, setQuestionIndex,
setExerciseIndex,
userSolutions,
setUserSolutions,
showSolutions,
setShowSolutions,
selectedModules,
setSelectedModules,
setUser, setUser,
inactivity, inactivity,
timeSpent, timeSpent,
@@ -58,11 +74,12 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
saveSession, saveSession,
setFlags, setFlags,
setShuffles, setShuffles,
evaluated,
} = useExamStore(); } = useExamStore();
const [isFetchingExams, setIsFetchingExams] = useState(false); const [isFetchingExams, setIsFetchingExams] = useState(false);
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length); const [isExamLoaded, setIsExamLoaded] = useState(
moduleIndex < selectedModules.length
);
useEffect(() => { useEffect(() => {
setIsExamLoaded(moduleIndex < selectedModules.length); setIsExamLoaded(moduleIndex < selectedModules.length);
@@ -89,13 +106,21 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
module, module,
avoidRepeated, avoidRepeated,
variant, variant,
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined, user?.type === "student" || user?.type === "developer"
), ? user.preferredGender
: undefined
)
); );
Promise.all(examPromises).then((values) => { Promise.all(examPromises).then((values) => {
setIsFetchingExams(false); setIsFetchingExams(false);
if (values.every((x) => !!x)) { if (values.every((x) => !!x)) {
dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } }) dispatch({
type: "INIT_EXAM",
payload: {
exams: values.map((x) => x!),
modules: selectedModules,
},
});
} else { } else {
toast.error("Something went wrong, please try again"); toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500); setTimeout(router.reload, 500);
@@ -106,7 +131,6 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, exams]); }, [selectedModules, exams]);
const reset = () => { const reset = () => {
resetStore(); resetStore();
setVariant("full"); setVariant("full");
@@ -114,71 +138,111 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
setShowAbandonPopup(false); setShowAbandonPopup(false);
}; };
useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
useEffect(() => { useEffect(() => {
if (flags.finalizeModule && !showSolutions && flags.pendingEvaluation) { setModuleLock(true);
if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) { }, [flags.finalizeModule]);
const exercisesToEvaluate = exam.exercises
.map(exercise => exercise.id);
setPendingExercises(exercisesToEvaluate); useEffect(() => {
if (flags.finalizeModule && !showSolutions) {
if (
exam &&
(exam.module === "writing" || exam.module === "speaking") &&
userSolutions.length > 0
) {
(async () => { (async () => {
await Promise.all( try {
const results = await Promise.all(
exam.exercises.map(async (exercise, index) => { exam.exercises.map(async (exercise, index) => {
if (exercise.type === "writing") if (exercise.type === "writing") {
await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url); const sol = await evaluateWritingAnswer(
user.id,
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") { sessionId,
await evaluateSpeakingAnswer( exercise,
index + 1,
userSolutions.find((x) => x.exercise === exercise.id)!,
exercise.attachment?.url
);
return sol;
}
if (
exercise.type === "interactiveSpeaking" ||
exercise.type === "speaking"
) {
const sol = await evaluateSpeakingAnswer(
user.id, user.id,
sessionId, sessionId,
exercise, exercise,
userSolutions.find((x) => x.exercise === exercise.id)!, userSolutions.find((x) => x.exercise === exercise.id)!,
index + 1, index + 1
); );
return sol;
} }
}), return null;
)
})();
}
}
}, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]);
useEvaluationPolling({ pendingExercises, setPendingExercises });
useEffect(() => {
if (flags.finalizeExam && moduleIndex !== -1) {
setModuleIndex(-1);
}
}, [flags.finalizeExam, moduleIndex, setModuleIndex]);
useEffect(() => {
if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) {
(async () => {
if (evaluated.length !== 0) {
setUserSolutions(
userSolutions.map(solution => {
const evaluatedSolution = evaluated.find(e => e.exercise === solution.exercise);
if (evaluatedSolution) {
return { ...solution, ...evaluatedSolution };
}
return solution;
}) })
); );
const updatedSolutions = userSolutions.map((solution) => {
const completed = results
.filter((r) => r !== null)
.find((c: any) => c.exercise === solution.exercise);
return completed || solution;
});
setUserSolutions(updatedSolutions);
} catch (error) {
console.error("Error during module evaluation:", error);
} finally {
setModuleLock(false);
} }
})();
} else {
setModuleLock(false);
}
}
}, [
exam,
showSolutions,
userSolutions,
sessionId,
user.id,
flags.finalizeModule,
setUserSolutions,
]);
useEffect(() => {
if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) {
(async () => {
setModuleIndex(-1);
await saveStats(); await saveStats();
await axios.get("/api/stats/update"); await axios.get("/api/stats/update");
setShowSolutions(true);
setFlags({ finalizeExam: false });
dispatch({ type: "UPDATE_EXAMS" })
})(); })();
} }
}, [
flags.finalizeExam,
moduleIndex,
saveStats,
setModuleIndex,
userSolutions,
moduleLock,
flags.finalizeModule,
]);
useEffect(() => {
if (
flags.finalizeExam &&
!userSolutions.some((s) => s.isDisabled) &&
!moduleLock
) {
setShowSolutions(true);
setFlags({ finalizeExam: false });
dispatch({ type: "UPDATE_EXAMS" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveStats, setFlags, setModuleIndex, evaluated, pendingExercises, setUserSolutions, flags]); }, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]);
const aggregateScoresByModule = (
const aggregateScoresByModule = (isPractice?: boolean): { isPractice?: boolean
): {
module: Module; module: Module;
total: number; total: number;
missing: number; missing: number;
@@ -214,29 +278,40 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
}, },
}; };
userSolutions.filter(x => isPractice ? x.isPractice : !x.isPractice).forEach((x) => { userSolutions.forEach((x) => {
if (isPractice ? x.isPractice : !x.isPractice) {
const examModule = const examModule =
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined); x.module ||
(x.type === "writing"
? "writing"
: x.type === "speaking" || x.type === "interactiveSpeaking"
? "speaking"
: undefined);
scores[examModule!] = { scores[examModule!] = {
total: scores[examModule!].total + x.score.total, total: scores[examModule!].total + x.score.total,
correct: scores[examModule!].correct + x.score.correct, correct: scores[examModule!].correct + x.score.correct,
missing: scores[examModule!].missing + x.score.missing, missing: scores[examModule!].missing + x.score.missing,
}; };
}
}); });
return Object.keys(scores) return Object.keys(scores).reduce<
.filter((x) => scores[x as Module].total > 0) { module: Module; total: number; missing: number; correct: number }[]
.map((x) => ({ module: x as Module, ...scores[x as Module] })); >((accm, x) => {
if (scores[x as Module].total > 0)
accm.push({ module: x as Module, ...scores[x as Module] });
return accm;
}, []);
}; };
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = { const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
"reading": Reading as React.ComponentType<ExamProps<Exam>>, reading: Reading as React.ComponentType<ExamProps<Exam>>,
"listening": Listening as React.ComponentType<ExamProps<Exam>>, listening: Listening as React.ComponentType<ExamProps<Exam>>,
"writing": Writing as React.ComponentType<ExamProps<Exam>>, writing: Writing as React.ComponentType<ExamProps<Exam>>,
"speaking": Speaking as React.ComponentType<ExamProps<Exam>>, speaking: Speaking as React.ComponentType<ExamProps<Exam>>,
"level": Level as React.ComponentType<ExamProps<Exam>>, level: Level as React.ComponentType<ExamProps<Exam>>,
} };
const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined; const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
@@ -245,38 +320,74 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
reset(); reset();
}; };
const {
setBgColor,
setHideSidebar,
setFocusMode,
setOnFocusLayerMouseEnter,
} = React.useContext(LayoutContext);
useEffect(() => {
setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setBgColor(bgColor);
setHideSidebar(hideSidebar);
setFocusMode(
selectedModules.length !== 0 &&
!showSolutions &&
moduleIndex < selectedModules.length
);
}, [
bgColor,
hideSidebar,
moduleIndex,
selectedModules.length,
setBgColor,
setFocusMode,
setHideSidebar,
showSolutions,
]);
return ( return (
<> <>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout
user={user}
bgColor={bgColor}
hideSidebar={hideSidebar}
className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
<> <>
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
{selectedModules.length === 0 && <Selection {selectedModules.length === 0 && (
<Selection
page={page} page={page}
user={user!} user={user!}
onStart={(modules: Module[], avoid: boolean, variant: Variant) => { onStart={(
modules: Module[],
avoid: boolean,
variant: Variant
) => {
setModuleIndex(0); setModuleIndex(0);
setAvoidRepeated(avoid); setAvoidRepeated(avoid);
setSelectedModules(modules); setSelectedModules(modules);
setVariant(variant); setVariant(variant);
}} }}
/>} />
)}
{isFetchingExams && ( {isFetchingExams && (
<div className="flex flex-grow flex-col items-center justify-center animate-pulse"> <div className="flex flex-grow flex-col items-center justify-center animate-pulse">
<span className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`} /> <span
<span className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}>Loading Exam ...</span> className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`}
/>
<span
className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
>
Loading Exam ...
</span>
</div> </div>
)} )}
{(moduleIndex === -1 && selectedModules.length !== 0) && {moduleIndex === -1 && selectedModules.length !== 0 && (
<Finish <Finish
isLoading={flags.pendingEvaluation} isLoading={userSolutions.some((s) => s.isDisabled)}
user={user!} user={user!}
modules={selectedModules} modules={selectedModules}
solutions={userSolutions} solutions={userSolutions}
@@ -289,11 +400,19 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
onViewResults={(index?: number) => { onViewResults={(index?: number) => {
if (exams[0].module === "level") { if (exams[0].module === "level") {
const levelExam = exams[0] as LevelExam; const levelExam = exams[0] as LevelExam;
const allExercises = levelExam.parts.flatMap((part) => part.exercises); const allExercises = levelExam.parts.flatMap(
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index])); (part) => part.exercises
const orderedSolutions = userSolutions.slice().sort((a, b) => { );
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity; const exerciseOrderMap = new Map(
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity; allExercises.map((ex, index) => [ex.id, index])
);
const orderedSolutions = userSolutions
.slice()
.sort((a, b) => {
const indexA =
exerciseOrderMap.get(a.exercise) ?? Infinity;
const indexB =
exerciseOrderMap.get(b.exercise) ?? Infinity;
return indexA - indexB; return indexA - indexB;
}); });
setUserSolutions(orderedSolutions); setUserSolutions(orderedSolutions);
@@ -316,12 +435,16 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
}} }}
scores={aggregateScoresByModule()} scores={aggregateScoresByModule()}
practiceScores={aggregateScoresByModule(true)} practiceScores={aggregateScoresByModule(true)}
/>} />
)}
{/* Exam is on going, display it and the abandon modal */} {/* Exam is on going, display it and the abandon modal */}
{isExamLoaded && moduleIndex !== -1 && ( {isExamLoaded && moduleIndex !== -1 && (
<> <>
{exam && CurrentExam && <CurrentExam exam={exam} showSolutions={showSolutions} />} {exam && CurrentExam && (
{!showSolutions && <AbandonPopup <CurrentExam exam={exam} showSolutions={showSolutions} />
)}
{!showSolutions && (
<AbandonPopup
isOpen={showAbandonPopup} isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise" abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard." abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
@@ -329,11 +452,10 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
onAbandon={onAbandon} onAbandon={onAbandon}
onCancel={() => setShowAbandonPopup(false)} onCancel={() => setShowAbandonPopup(false)}
/> />
} )}
</> </>
)} )}
</> </>
</Layout>
)} )}
</> </>
); );

View File

@@ -1,18 +1,14 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import useGroups from "@/hooks/useGroups";
import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize, sortBy } from "lodash"; import { capitalize } from "lodash";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import { BsArrowRepeat } from "react-icons/bs"; import { BsArrowRepeat } from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment"; import PaymobPayment from "@/components/PaymobPayment";
import moment from "moment"; import moment from "moment";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
@@ -22,44 +18,65 @@ import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
interface Props { interface Props {
user: User user: User;
discounts: Discount[] discounts: Discount[];
packages: Package[] packages: Package[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
hasExpired?: boolean; hasExpired?: boolean;
reload: () => void; reload: () => void;
} }
export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) { export default function PaymentDue({
user,
discounts = [],
entities = [],
packages = [],
hasExpired = false,
reload,
}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [entity, setEntity] = useState<EntityWithRoles>() const [entity, setEntity] = useState<EntityWithRoles>();
const router = useRouter(); const router = useRouter();
const { users } = useUsers(); const { users } = useUsers();
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); const {
invites,
isLoading: isInvitesLoading,
reload: reloadInvites,
} = useInvites({ to: user?.id });
const isIndividual = useMemo(() => { const isIndividual = useMemo(() => {
if (isAdmin(user)) return false; if (isAdmin(user)) return false;
if (user?.type !== "student") return false; if (user?.type !== "student") return false;
return user.entities.length === 0 return user.entities.length === 0;
}, [user]) }, [user]);
const appliedDiscount = useMemo(() => { const appliedDiscount = useMemo(() => {
const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift(); const biggestDiscount = [...discounts]
.sort((a, b) => b.percentage - a.percentage)
.shift();
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) if (
!biggestDiscount ||
(biggestDiscount.validUntil &&
moment(biggestDiscount.validUntil).isBefore(moment()))
)
return 0; return 0;
return biggestDiscount.percentage return biggestDiscount.percentage;
}, [discounts]) }, [discounts]);
const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity') const entitiesThatCanBePaid = useAllowedEntities(
user,
entities,
"pay_entity"
);
useEffect(() => { useEffect(() => {
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]) if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]);
}, [entitiesThatCanBePaid]) }, [entitiesThatCanBePaid]);
return ( return (
<> <>
@@ -67,26 +84,42 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
{isLoading && ( {isLoading && (
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60"> <div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-8 text-white"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-8 text-white">
<span className={clsx("loading loading-infinity w-48 animate-pulse")} /> <span
<span className={clsx("text-2xl font-bold animate-pulse")}>Completing your payment...</span> className={clsx("loading loading-infinity w-48 animate-pulse")}
<span>If you canceled your payment or it failed, please click the button below to restart</span> />
<span className={clsx("text-2xl font-bold animate-pulse")}>
Completing your payment...
</span>
<span>
If you canceled your payment or it failed, please click the button
below to restart
</span>
<button <button
onClick={() => setIsLoading(false)} onClick={() => setIsLoading(false)}
className="border border-white rounded-full px-4 py-2 hover:bg-white/80 hover:text-black cursor-pointer transition ease-in-out duration-300"> className="border border-white rounded-full px-4 py-2 hover:bg-white/80 hover:text-black cursor-pointer transition ease-in-out duration-300"
>
Cancel Payment Cancel Payment
</button> </button>
</div> </div>
</div> </div>
)} )}
<Layout user={user} navDisabled={hasExpired}> <>
{invites.length > 0 && ( {invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div
onClick={reloadInvites} onClick={reloadInvites}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<span className="text-mti-black text-lg font-bold">Invites</span> >
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} /> <span className="text-mti-black text-lg font-bold">
Invites
</span>
<BsArrowRepeat
className={clsx(
"text-xl",
isInvitesLoading && "animate-spin"
)}
/>
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
@@ -106,21 +139,40 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
)} )}
<div className="flex w-full flex-col items-center justify-center gap-4 text-center"> <div className="flex w-full flex-col items-center justify-center gap-4 text-center">
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>} {hasExpired && (
<span className="text-lg font-bold">
You do not have time credits for your account type!
</span>
)}
{isIndividual && ( {isIndividual && (
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll"> <div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below: To add to your use of EnCoach, please purchase one of the time
packages available below:
</span> </span>
<div className="flex w-full flex-wrap justify-center gap-8"> <div className="flex w-full flex-wrap justify-center gap-8">
{packages.map((p) => ( {packages.map((p) => (
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> <div
key={p.id}
className={clsx(
"flex flex-col items-start gap-6 rounded-xl bg-white p-4"
)}
>
<div className="mb-2 flex flex-col items-start"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <img
src="/logo_title.png"
alt="EnCoach's Logo"
className="w-32"
/>
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
EnCoach - {p.duration}{" "} EnCoach - {p.duration}{" "}
{capitalize( {capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, p.duration === 1
? p.duration_unit.slice(
0,
p.duration_unit.length - 1
)
: p.duration_unit
)} )}
</span> </span>
</div> </div>
@@ -136,7 +188,11 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
{p.price} {p.currency} {p.price} {p.currency}
</span> </span>
<span className="text-2xl text-mti-red-light"> <span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} {(
p.price -
p.price * (appliedDiscount / 100)
).toFixed(2)}{" "}
{p.currency}
</span> </span>
</div> </div>
)} )}
@@ -149,15 +205,24 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
currency={p.currency} currency={p.currency}
duration={p.duration} duration={p.duration}
duration_unit={p.duration_unit} duration_unit={p.duration_unit}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} price={
+(
p.price -
p.price * (appliedDiscount / 100)
).toFixed(2)
}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li> <li>- Train your abilities for the IELTS exam</li>
<li>- Gain insights into your weaknesses and strengths</li> <li>
<li>- Allow yourself to correctly prepare for the exam</li> - Gain insights into your weaknesses and strengths
</li>
<li>
- Allow yourself to correctly prepare for the exam
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -166,26 +231,43 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
</div> </div>
)} )}
{!isIndividual && entitiesThatCanBePaid.length > 0 && {!isIndividual &&
entitiesThatCanBePaid.length > 0 &&
entity?.payment && ( entity?.payment && (
<div className="flex flex-col items-center gap-8"> <div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}> <div
<label className="font-normal text-base text-mti-gray-dim">Entity</label> className={clsx("flex flex-col items-center gap-4 w-full")}
>
<label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select <Select
defaultValue={{ value: entity?.id, label: entity?.label }} defaultValue={{ value: entity?.id, label: entity?.label }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))} options={entitiesThatCanBePaid.map((e) => ({
onChange={(e) => e?.value ? setEntity(e?.entity) : null} value: e.id,
label: e.label,
entity: e,
}))}
onChange={(e) => (e?.value ? setEntity(e?.entity) : null)}
className="!w-full max-w-[400px] self-center" className="!w-full max-w-[400px] self-center"
/> />
</div> </div>
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach and that of your students and teachers, please pay your designated package To add to your use of EnCoach and that of your students and
below: teachers, please pay your designated package below:
</span> </span>
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> <div
className={clsx(
"flex flex-col items-start gap-6 rounded-xl bg-white p-4"
)}
>
<div className="mb-2 flex flex-col items-start"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <img
src="/logo_title.png"
alt="EnCoach's Logo"
className="w-32"
/>
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
EnCoach - {12} Months EnCoach - {12} Months
</span> </span>
@@ -212,10 +294,14 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li> <li>
- Allow a total of {entity.licenses} students and teachers to use EnCoach - Allow a total of {entity.licenses} students and
teachers to use EnCoach
</li> </li>
<li>- Train their abilities for the IELTS exam</li> <li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li> <li>
- Gain insights into your students&apos; weaknesses and
strengths
</li>
<li>- Allow them to correctly prepare for the exam</li> <li>- Allow them to correctly prepare for the exam</li>
</ul> </ul>
</div> </div>
@@ -225,11 +311,12 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
{!isIndividual && entitiesThatCanBePaid.length === 0 && ( {!isIndividual && entitiesThatCanBePaid.length === 0 && (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="max-w-lg"> <span className="max-w-lg">
You are not the person in charge of your time credits, please contact your administrator about this situation. You are not the person in charge of your time credits, please
contact your administrator about this situation.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your If you believe this to be a mistake, please contact the
patience. platform&apos;s administration, thank you for your patience.
</span> </span>
</div> </div>
)} )}
@@ -237,26 +324,39 @@ export default function PaymentDue({ user, discounts = [], entities = [], packag
entitiesThatCanBePaid.length > 0 && entitiesThatCanBePaid.length > 0 &&
!entity?.payment && ( !entity?.payment && (
<div className="flex flex-col items-center gap-8"> <div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}> <div
<label className="font-normal text-base text-mti-gray-dim">Entity</label> className={clsx("flex flex-col items-center gap-4 w-full")}
>
<label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select <Select
defaultValue={{ value: entity?.id || "", label: entity?.label || "" }} defaultValue={{
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))} value: entity?.id || "",
onChange={(e) => e?.value ? setEntity(e?.entity) : null} label: entity?.label || "",
}}
options={entitiesThatCanBePaid.map((e) => ({
value: e.id,
label: e.label,
entity: e,
}))}
onChange={(e) => (e?.value ? setEntity(e?.entity) : null)}
className="!w-full max-w-[400px] self-center" className="!w-full max-w-[400px] self-center"
/> />
</div> </div>
<span className="max-w-lg"> <span className="max-w-lg">
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users An admin nor your agent have yet set the price intended to
you desire and your expected monthly duration. your requirements in terms of the amount of users you desire
and your expected monthly duration.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
Please try again later or contact your agent or an admin, thank you for your patience. Please try again later or contact your agent or an admin,
thank you for your patience.
</span> </span>
</div> </div>
)} )}
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,24 +1,50 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import type {AppProps} from "next/app"; import type { AppProps } from "next/app";
import "primereact/resources/themes/lara-light-indigo/theme.css"; import "primereact/resources/themes/lara-light-indigo/theme.css";
import "primereact/resources/primereact.min.css"; import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css"; import "primeicons/primeicons.css";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
import {useRouter} from "next/router"; import { Router, useRouter } from "next/router";
import {useEffect} from "react"; import { useEffect, useState } from "react";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import Layout from "../components/High/Layout";
import useEntities from "../hooks/useEntities";
export default function App({ Component, pageProps }: AppProps) {
const [loading, setLoading] = useState(false);
export default function App({Component, pageProps}: AppProps) { const { reset } = useExamStore();
const {reset} = useExamStore();
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized); const setIsSidebarMinimized = usePreferencesStore(
(state) => state.setSidebarMinimized
);
const router = useRouter(); const router = useRouter();
const { entities } = useEntities(!pageProps?.user?.id);
useEffect(() => { useEffect(() => {
if (router.pathname !== "/exam" && router.pathname !== "/exercises") reset(); const start = () => {
setLoading(true);
};
const end = () => {
setLoading(false);
};
Router.events.on("routeChangeStart", start);
Router.events.on("routeChangeComplete", end);
Router.events.on("routeChangeError", end);
return () => {
Router.events.off("routeChangeStart", start);
Router.events.off("routeChangeComplete", end);
Router.events.off("routeChangeError", end);
};
}, []);
useEffect(() => {
if (router.pathname !== "/exam" && router.pathname !== "/exercises")
reset();
}, [router.pathname, reset]); }, [router.pathname, reset]);
useEffect(() => { useEffect(() => {
@@ -31,5 +57,18 @@ export default function App({Component, pageProps}: AppProps) {
} }
}, [setIsSidebarMinimized]); }, [setIsSidebarMinimized]);
return <Component {...pageProps} />; return pageProps?.user ? (
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
{loading ? (
// TODO: Change this later to a better loading screen (example: skeletons for each page)
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
<Component entities={entities} {...pageProps} />
)}
</Layout>
) : (
<Component {...pageProps} />
);
} }

View File

@@ -0,0 +1,32 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import { ObjectId } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "PUT") return await put(req, res);
}
async function put(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
const { id } = req.query as { id?: string };
const approvalWorkflow: ApprovalWorkflow = req.body;
if (id && approvalWorkflow) {
approvalWorkflow._id = new ObjectId(id);
await updateApprovalWorkflow("active-workflows", approvalWorkflow);
return res.status(204).end();
}
}

View File

@@ -0,0 +1,62 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { deleteApprovalWorkflow, getApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import { ObjectId } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PUT") return await put(req, res);
if (req.method === "GET") return await get(req, res);
}
async function del(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
const { id } = req.query as { id?: string };
if (id) return res.status(200).json(await deleteApprovalWorkflow("active-workflows", id));
}
async function put(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
const { id } = req.query as { id?: string };
const workflow: ApprovalWorkflow = req.body;
if (id && workflow) {
workflow._id = new ObjectId(id);
await updateApprovalWorkflow("active-workflows", workflow);
return res.status(204).end();
}
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
const { id } = req.query as { id?: string };
if (id) {
return res.status(200).json(await getApprovalWorkflow("active-workflows", id));
}
}

View File

@@ -0,0 +1,37 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { Entity } from "@/interfaces/entity";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { replaceApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(handler, sessionOptions);
interface ReplaceApprovalWorkflowsRequest {
filteredWorkflows: ApprovalWorkflow[];
userEntitiesWithLabel: Entity[];
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return await post(req, res);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
const { filteredWorkflows, userEntitiesWithLabel } = req.body as ReplaceApprovalWorkflowsRequest;
const configuredWorkflows: ApprovalWorkflow[] = filteredWorkflows;
const entitiesIds: string[] = userEntitiesWithLabel.map((e) => e.id);
await replaceApprovalWorkflowsByEntities(configuredWorkflows, entitiesIds);
return res.status(204).end();
}

View File

@@ -0,0 +1,78 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { Module } from "@/interfaces";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { createApprovalWorkflow, getApprovalWorkflowByFormIntaker, getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(handler, sessionOptions);
interface PostRequestBody {
examAuthor: string;
examEntities: string[];
examId: string;
examName: string;
examModule: Module;
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
if (req.method === "POST") return await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
return res.status(200).json(await getApprovalWorkflows("active-workflows"));
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
const { examAuthor, examEntities, examId, examModule } = req.body as PostRequestBody;
const results = await Promise.all(
examEntities.map(async (entity) => {
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
if (!configuredWorkflow) {
return { entity, created: false, error: "No configured workflow found for examAuthor." };
}
configuredWorkflow.modules.push(examModule);
configuredWorkflow.name = `${examId}`;
configuredWorkflow.examId = examId;
configuredWorkflow.entityId = entity;
configuredWorkflow.startDate = Date.now();
try {
const creationResponse = await createApprovalWorkflow("active-workflows", configuredWorkflow);
return { entity, created: true, creationResponse };
} catch (error) {
const err = error as Error;
return { entity, created: false, error: err.message };
}
})
);
const successCount = results.filter((r) => r.created).length;
const totalCount = examEntities.length;
if (successCount === totalCount) {
return res.status(200).json({ ok: true, results });
} else if (successCount > 0) {
return res.status(207).json({ ok: true, results });
} else {
return res.status(404).json({ ok: false, message: "No workflows were created", results });
}
}

View File

@@ -4,41 +4,31 @@ import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { UserSolution } from "@/interfaces/exam"; import { UserSolution } from "@/interfaces/exam";
import { speakingReverseMarking, writingReverseMarking } from "@/utils/score"; import { speakingReverseMarking, writingReverseMarking } from "@/utils/score";
import { Stat } from "@/interfaces/user";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} }
const { sessionId, userId, userSolutions } = req.body; try {
const completedEvals = await db.collection("evaluation").find({ return await getSessionEvals(req, res);
session_id: sessionId, } catch (error) {
user: userId, console.error(error);
status: "completed" res.status(500).json({ ok: false });
}).toArray(); }
}
const evalsByExercise = new Map( function formatSolutionWithEval(userSolution: UserSolution | Stat, evaluation: any) {
completedEvals.map(e => [e.exercise_id, e]) if (userSolution.type === 'writing') {
);
const solutionsWithEvals = userSolutions.filter((solution: UserSolution) =>
evalsByExercise.has(solution.exercise)
).map((solution: any) => {
const evaluation = evalsByExercise.get(solution.exercise)!;
if (solution.type === 'writing') {
return { return {
...solution, ...userSolution,
solutions: [{ solutions: [{
...solution.solutions[0], ...userSolution.solutions[0],
evaluation: evaluation.result evaluation: evaluation.result
}], }],
score: { score: {
@@ -50,13 +40,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}; };
} }
if (solution.type === 'speaking' || solution.type === 'interactiveSpeaking') { if (userSolution.type === 'speaking' || userSolution.type === 'interactiveSpeaking') {
return { return {
...solution, ...userSolution,
solutions: [{ solutions: [{
...solution.solutions[0], ...userSolution.solutions[0],
...( ...(
solution.type === 'speaking' userSolution.type === 'speaking'
? { fullPath: evaluation.result.fullPath } ? { fullPath: evaluation.result.fullPath }
: { answer: evaluation.result.answer } : { answer: evaluation.result.answer }
), ),
@@ -70,11 +60,30 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
isDisabled: false isDisabled: false
}; };
} }
return { return {
solution, solution: userSolution,
evaluation evaluation
}; };
}); }
res.status(200).json(solutionsWithEvals) async function getSessionEvals(req: NextApiRequest, res: NextApiResponse) {
const { sessionId, userId, stats } = req.body;
const completedEvals = await db.collection("evaluation").find({
session_id: sessionId,
user: userId,
status: "completed"
}).toArray();
const evalsByExercise = new Map(
completedEvals.map(e => [e.exercise_id, e])
);
const statsWithEvals = stats
.filter((solution: UserSolution | Stat) => evalsByExercise.has(solution.exercise))
.map((solution: UserSolution | Stat) =>
formatSolutionWithEval(solution, evalsByExercise.get(solution.exercise)!)
);
res.status(200).json(statsWithEvals);
} }

View File

@@ -11,19 +11,100 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
} }
type Query = {
op: string;
sessionId: string;
userId: string;
}
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); return res.status(401).json({ ok: false });
return;
} }
const { sessionId, userId } = req.query; const { sessionId, userId, op } = req.query as Query;
switch (op) {
case 'pending':
return getPendingEvaluation(userId, sessionId, res);
case 'disabled':
return getSessionsWIthDisabledWithPending(userId, res);
default:
return res.status(400).json({
ok: false,
});
}
}
async function getPendingEvaluation(
userId: string,
sessionId: string,
res: NextApiResponse
) {
const singleEval = await db.collection("evaluation").findOne({ const singleEval = await db.collection("evaluation").findOne({
session_id: sessionId, session_id: sessionId,
user: userId, user: userId,
status: "pending", status: "pending",
}); });
return res.status(200).json({ hasPendingEvaluation: singleEval !== null });
res.status(200).json({ hasPendingEvaluation: singleEval !== null}); }
async function getSessionsWIthDisabledWithPending(
userId: string,
res: NextApiResponse
) {
const sessions = await db.collection("stats")
.aggregate([
{
$match: {
user: userId,
disabled: true
}
},
{
$project: {
_id: 0,
session: 1
}
},
{
$lookup: {
from: "evaluation",
let: { sessionId: "$session" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$session", "$$sessionId"] },
{ $eq: ["$user", userId] },
{ $eq: ["$status", "pending"] }
]
}
}
},
{
$project: {
_id: 1
}
}
],
as: "pendingEvals"
}
},
{
$match: {
"pendingEvals.0": { $exists: true }
}
},
{
$group: {
id: "$session"
}
}
]).toArray();
return res.status(200).json({
sessions: sessions.map(s => s.id)
});
} }

View File

@@ -40,13 +40,13 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
} }
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
const { module } = req.query as { module: string }; const { module } = req.query as { module: string };
const session = client.startSession(); const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id') const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
try { try {
const exam = { const exam = {
@@ -62,9 +62,12 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// Check whether the id of the exam matches another exam with different // Check whether the id of the exam matches another exam with different
// owners, throw exception if there is, else allow editing // owners, throw exception if there is, else allow editing
const ownersSet = new Set(docSnap?.owners || []); const existingExamOwners = docSnap?.owners ?? [];
const newExamOwners = exam.owners ?? [];
if (docSnap !== null && docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) { const ownersSet = new Set(existingExamOwners);
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
throw new Error("Name already exists"); throw new Error("Name already exists");
} }
@@ -73,13 +76,12 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
{ $set: { id: req.body.id, ...exam } }, { $set: { id: req.body.id, ...exam } },
{ {
upsert: true, upsert: true,
session session,
} }
); );
}); });
res.status(200).json(exam); res.status(200).json(exam);
} catch (error) { } catch (error) {
console.error("Transaction failed: ", error); console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message }); res.status(500).json({ ok: false, error: (error as any).message });

View File

@@ -26,7 +26,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const q = user ? { user: user } : {}; const q = user ? { user: user } : {};
const sessions = await db.collection("sessions").find<Session>({ const sessions = await db.collection("sessions").find<Session>({
...q, ...q,
}).limit(12).toArray(); }).limit(12).sort({ date: -1 }).toArray();
console.log(sessions) console.log(sessions)
res.status(200).json( res.status(200).json(

View File

@@ -3,37 +3,41 @@ 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 { Stat } from "@/interfaces/user";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { UserSolution } from "@/interfaces/exam"; import { UserSolution } from "@/interfaces/exam";
import { WithId } from "mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
}
interface Body {
solutions: UserSolution[];
sessionID: string;
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
const { solutions, sessionID } = req.body as Body; if (req.method === "POST") return post(req, res);
}
const disabledStats = await db.collection("stats").find({ user: user.id, session: sessionID, disabled: true }).toArray();
interface Body {
solutions: UserSolution[];
sessionId: string;
userId: string;
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const { userId, solutions, sessionId } = req.body as Body;
const disabledStats = await db.collection("stats").find(
{ user: userId, session: sessionId, isDisabled: true }
).toArray();
await Promise.all(disabledStats.map(async (stat) => { await Promise.all(disabledStats.map(async (stat) => {
const matchingSolution = solutions.find(s => s.exercise === stat.exercise); const matchingSolution = solutions.find(s => s.exercise === stat.exercise);
if (matchingSolution) { if (matchingSolution) {
const { _id, ...updateFields } = matchingSolution as WithId<UserSolution>;
await db.collection("stats").updateOne( await db.collection("stats").updateOne(
{ id: stat.id }, { id: stat.id },
{ $set: { ...matchingSolution } } { $set: { ...updateFields } }
); );
} }
})); }));

View File

@@ -0,0 +1,21 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {session} = req.query;
const snapshot = await db.collection("stats").find({ user: req.session.user.id, session }).toArray();
res.status(200).json(snapshot);
}

View File

@@ -1,21 +1,20 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
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 { getDetailedStatsByUser } from "../../../../utils/stats.be";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const {user} = req.query; const { user, query } = req.query as { user: string, query?: string };
const snapshot = await db.collection("stats").find({ user: user }).toArray();
const snapshot = await getDetailedStatsByUser(user, query);
res.status(200).json(snapshot); res.status(200).json(snapshot);
} }

View File

@@ -0,0 +1,39 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { Ticket, TicketWithCorporate } from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session";
import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
import { Group, CorporateUser } from "@/interfaces/user";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
// specific logic for the preflight request
if (req.method === "OPTIONS") {
res.status(200).end();
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") {
await get(req, res);
}
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id, status: { $ne: "completed" } }).toArray();
res.status(200).json(docs);
}

View File

@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { searchUsers } from "@/utils/users.be";
import { Type } from "@/interfaces/user";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user && !req.headers["page"] && req.headers["page"] !== "register") {
res.status(401).json({ ok: false });
return;
}
const {
value,
size,
page,
orderBy = "name",
direction = "asc",
type,
entities
} = req.query as { value?: string, size?: string; type?: Type; page?: string; orderBy?: string; direction?: "asc" | "desc", entities?: string };
const { users, total } = await searchUsers(
value,
size !== undefined ? parseInt(size) : undefined,
page !== undefined ? parseInt(page) : undefined,
{
[orderBy]: direction === "asc" ? 1 : -1,
},
{},
{
...(type ? { "type": type } : {}),
...(entities ? { "entities.id": entities.split(',') } : {})
}
);
res.status(200).json({ users, total });
}

View File

@@ -0,0 +1,192 @@
import RequestedBy from "@/components/ApprovalWorkflows/RequestedBy";
import StartedOn from "@/components/ApprovalWorkflows/StartedOn";
import Status from "@/components/ApprovalWorkflows/Status";
import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
import Layout from "@/components/High/Layout";
import { ApprovalWorkflow, EditableApprovalWorkflow, EditableWorkflowStep, getUserTypeLabelShort } from "@/interfaces/approval.workflow";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getEntityUsers } from "@/utils/users.be";
import axios from "axios";
import { LayoutGroup, motion } from "framer-motion";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import { useEffect, useState } from "react";
import { BsChevronLeft } from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login")
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
return redirect("/")
const { id } = params as { id: string };
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id);
if (!workflow)
return redirect("/approval-workflows")
return {
props: serialize({
user,
workflow,
workflowEntityApprovers: await getEntityUsers(workflow.entityId, undefined, { type: { $in: ["teacher", "corporate", "mastercorporate", "developer"] } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}),
};
}, sessionOptions);
interface Props {
user: User,
workflow: ApprovalWorkflow,
workflowEntityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}
export default function Home({ user, workflow, workflowEntityApprovers }: Props) {
const [updatedWorkflow, setUpdatedWorkflow] = useState<EditableApprovalWorkflow | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
useEffect(() => {
const editableSteps: EditableWorkflowStep[] = workflow.steps.map(step => ({
key: step.stepNumber + 999, // just making sure they are unique because new steps that users add will have key=3 key=4 etc
stepType: step.stepType,
stepNumber: step.stepNumber,
completed: step.completed,
completedBy: step.completedBy || undefined,
completedDate: step.completedDate || undefined,
assignees: step.assignees,
firstStep: step.firstStep || false,
finalStep: step.finalStep || false,
onDelete: undefined,
}));
const editableWorkflow: EditableApprovalWorkflow = {
id: workflow._id?.toString() ?? "",
name: workflow.name,
entityId: workflow.entityId,
requester: user.id, // should it change to the editor?
startDate: workflow.startDate,
modules: workflow.modules,
status: workflow.status,
steps: editableSteps,
};
setUpdatedWorkflow(editableWorkflow);
}, []);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
if (!updatedWorkflow) {
setIsLoading(false);
return;
}
for (const step of updatedWorkflow.steps) {
if (step.assignees.every(x => !x)) {
toast.warning("There is at least one empty step in the workflow.");
setIsLoading(false);
return;
}
}
const filteredWorkflow: ApprovalWorkflow = {
...updatedWorkflow,
steps: updatedWorkflow.steps.map(step => ({
...step,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
}))
};
axios
.put(`/api/approval-workflows/${updatedWorkflow.id}/edit`, filteredWorkflow)
.then(() => {
toast.success("Approval Workflow edited successfully.");
setIsLoading(false);
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to edit Approval Workflows!");
} else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false);
console.log("Submitted Values:", filteredWorkflow);
return;
})
};
const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
setUpdatedWorkflow(updatedWorkflow);
};
return (
<>
<Head>
<title> Edit Workflow | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<section className="flex items-center gap-2">
<Link
href="/approval-workflows"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h1 className="text-2xl font-semibold">{workflow.name}</h1>
</section>
<section className="flex flex-col gap-6">
<div className="flex flex-row gap-6">
<RequestedBy
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
<StartedOn
date={workflow.startDate}
/>
<Status
status={workflow.status}
/>
</div>
</section>
<form onSubmit={handleSubmit}>
<LayoutGroup key={workflow.name}>
<motion.div
key="form"
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 60 }}
transition={{ duration: 0.20 }}
>
{updatedWorkflow &&
<WorkflowForm
workflow={updatedWorkflow}
onWorkflowChange={onWorkflowChange}
entityApprovers={workflowEntityApprovers}
isLoading={isLoading}
/>
}
</motion.div>
</LayoutGroup>
</form>
</>
);
}

View File

@@ -0,0 +1,559 @@
import RequestedBy from "@/components/ApprovalWorkflows/RequestedBy";
import StartedOn from "@/components/ApprovalWorkflows/StartedOn";
import Status from "@/components/ApprovalWorkflows/Status";
import Tip from "@/components/ApprovalWorkflows/Tip";
import UserWithProfilePic from "@/components/ApprovalWorkflows/UserWithProfilePic";
import WorkflowStepComponent from "@/components/ApprovalWorkflows/WorkflowStepComponent";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import useApprovalWorkflow from "@/hooks/useApprovalWorkflow";
import { ApprovalWorkflow, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { getExamById } from "@/utils/exams";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getSpecificUsers, getUser } from "@/utils/users.be";
import axios from "axios";
import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { BsChevronLeft } from "react-icons/bs";
import { FaSpinner, FaWpforms } from "react-icons/fa6";
import { FiSave } from "react-icons/fi";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { IoDocumentTextOutline } from "react-icons/io5";
import { MdOutlineDoubleArrow } from "react-icons/md";
import { RiThumbUpLine } from "react-icons/ri";
import { RxCrossCircled } from "react-icons/rx";
import { TiEdit } from "react-icons/ti";
import { toast, ToastContainer } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login")
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
return redirect("/")
const { id } = params as { id: string };
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id);
if (!workflow)
return redirect("/approval-workflows")
const allAssigneeIds: string[] = [
...new Set(
workflow.steps
.map(step => step.assignees)
.flat()
)
];
return {
props: serialize({
user,
initialWorkflow: workflow,
id,
workflowAssignees: await getSpecificUsers(allAssigneeIds),
workflowRequester: await getUser(workflow.requester),
}),
};
}, sessionOptions);
interface Props {
user: User,
initialWorkflow: ApprovalWorkflow,
id: string,
workflowAssignees: User[],
workflowRequester: User,
}
export default function Home({ user, initialWorkflow, id, workflowAssignees, workflowRequester }: Props) {
const { workflow, reload, isLoading } = useApprovalWorkflow(id);
const currentWorkflow = workflow || initialWorkflow;
let currentStepIndex = currentWorkflow.steps.findIndex(step => !step.completed || step.rejected);
if (currentStepIndex === -1)
currentStepIndex = currentWorkflow.steps.length - 1;
const [selectedStepIndex, setSelectedStepIndex] = useState<number>(currentStepIndex);
const [selectedStep, setSelectedStep] = useState<WorkflowStep>(currentWorkflow.steps[selectedStepIndex]);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [comments, setComments] = useState<string>(selectedStep.comments || "");
const [viewExamIsLoading, setViewExamIsLoading] = useState<boolean>(false);
const [editExamIsLoading, setEditExamIsLoading] = useState<boolean>(false);
const router = useRouter();
const handleStepClick = (index: number, stepInfo: WorkflowStep) => {
setSelectedStep(stepInfo);
setSelectedStepIndex(index);
setComments(stepInfo.comments || "");
setIsPanelOpen(true);
};
const handleSaveComments = () => {
const updatedWorkflow: ApprovalWorkflow = {
...currentWorkflow,
steps: currentWorkflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
comments: comments,
}
: step
)
};
axios
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
.then(() => {
toast.success("Comments saved successfully.");
reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
console.log("Submitted Values:", updatedWorkflow);
return;
})
};
const handleApproveStep = () => {
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
if (isLastStep) {
if (!confirm(`Are you sure you want to approve the last step? Doing so will approve the exam.`)) return;
}
const updatedWorkflow: ApprovalWorkflow = {
...currentWorkflow,
status: selectedStepIndex === currentWorkflow.steps.length - 1 ? "approved" : "pending",
steps: currentWorkflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
completed: true,
completedBy: user.id,
completedDate: Date.now(),
}
: step
)
};
axios
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
.then(() => {
toast.success("Step approved successfully.");
reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
console.log("Submitted Values:", updatedWorkflow);
return;
})
if (isLastStep) {
setIsPanelOpen(false);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
axios
.patch(`/api/exam/${examModule}/${examId}`, { isDiagnostic: false })
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
} else {
handleStepClick(selectedStepIndex + 1, currentWorkflow.steps[selectedStepIndex + 1]);
}
};
const handleRejectStep = () => {
if (!confirm(`Are you sure you want to reject this step? Doing so will terminate this approval workflow.`)) return;
const updatedWorkflow: ApprovalWorkflow = {
...currentWorkflow,
status: "rejected",
steps: currentWorkflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
completed: true,
completedBy: user.id,
completedDate: Date.now(),
rejected: true,
}
: step
)
};
axios
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
.then(() => {
toast.success("Step rejected successfully.");
reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
console.log("Submitted Values:", updatedWorkflow);
return;
})
};
const dispatch = useExamStore((store) => store.dispatch);
const handleViewExam = async () => {
setViewExamIsLoading(true);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
if (examModule && examId) {
const exam = await getExamById(examModule, examId.trim());
if (!exam) {
toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{ toastId: "invalid-exam-id" }
);
setViewExamIsLoading(false);
return;
}
dispatch({
type: "INIT_EXAM",
payload: { exams: [exam], modules: [examModule] },
});
router.push("/exam");
}
}
const handleEditExam = () => {
setEditExamIsLoading(true);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
router.push(`/generation?id=${examId}&module=${examModule}`);
}
return (
<>
<Head>
<title> Workflow | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<section className="flex items-center gap-2">
<Link
href="/approval-workflows"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h1 className="text-2xl font-semibold">{currentWorkflow.name}</h1>
</section>
<section className="flex flex-col gap-6">
<div className="flex flex-row gap-6">
<RequestedBy
prefix={getUserTypeLabelShort(workflowRequester.type)}
name={workflowRequester.name}
profileImage={workflowRequester.profilePicture}
/>
<StartedOn
date={currentWorkflow.startDate}
/>
<Status
status={currentWorkflow.status}
/>
</div>
<div className="flex flex-row gap-3">
<Button
color="purple"
variant="solid"
onClick={handleViewExam}
disabled={viewExamIsLoading}
padding="px-6 py-2"
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
{viewExamIsLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoDocumentTextOutline />
Load Exam
</>
)}
</Button>
<Button
color="purple"
variant="solid"
onClick={handleEditExam}
padding="px-6 py-2"
disabled={(!currentWorkflow.steps[currentStepIndex].assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || editExamIsLoading}
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
{editExamIsLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<TiEdit size={20} />
Edit Exam
</>
)}
</Button>
</div>
{currentWorkflow.steps.find((step) => !step.completed) === undefined &&
<Tip text="All steps in this instance have been completed." />
}
</section>
<section className="flex flex-col gap-0">
{currentWorkflow.steps.map((step, index) => (
<WorkflowStepComponent
workflowAssignees={workflowAssignees}
key={index}
completed={step.completed}
completedBy={step.completedBy}
rejected={step.rejected}
stepNumber={step.stepNumber}
stepType={step.stepType}
assignees={step.assignees}
finalStep={index === currentWorkflow.steps.length - 1}
currentStep={index === currentStepIndex}
selected={index === selectedStepIndex}
onClick={() => handleStepClick(index, step)}
/>
))}
</section>
{/* Side panel */}
<AnimatePresence mode="wait">
<LayoutGroup key="sidePanel">
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
{isPanelOpen && selectedStep && (
<motion.div
className="p-6"
key={selectedStep.stepNumber}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.2 }}
>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<div className="flex flex-row gap-2">
<p className="text-2xl font-medium text-left align-middle">Step {selectedStepIndex + 1}</p>
<div className="ml-auto flex flex-row">
<button
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
onClick={() => setIsPanelOpen(false)}
>
Collapse
<MdOutlineDoubleArrow size={20} />
</button>
</div>
</div>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<div>
<div className="my-8 flex flex-row gap-4 items-center text-lg font-medium">
{selectedStep.stepType === "approval-by" ? (
<>
<RiThumbUpLine size={30} />
Approval Step
</>
) : (
<>
<FaWpforms size={30} />
Form Intake Step
</>
)
}
</div>
{selectedStep.completed ? (
<div className={"text-base font-medium text-gray-500 flex flex-col gap-6"}>
{selectedStep.rejected ? "Rejected" : "Approved"} on {new Date(selectedStep.completedDate!).toLocaleString("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).replace(", ", " at ")}
<div className="flex flex-row gap-1 text-sm">
<p className="text-base">{selectedStep.rejected ? "Rejected" : "Approved"} by:</p>
{(() => {
const assignee = workflowAssignees.find(
(assignee) => assignee.id === selectedStep.completedBy
);
return assignee ? (
<UserWithProfilePic
textSize="text-base"
prefix={getUserTypeLabelShort(assignee.type)}
name={assignee.name}
profileImage={assignee.profilePicture}
/>
) : (
"Unknown"
);
})()}
</div>
<p className="text-sm">No additional actions are required.</p>
</div>
) : (
<div className={"text-base font-medium text-gray-500 mb-6"}>
One assignee is required to sign off to complete this step:
<div className="flex flex-col gap-2 mt-3">
{workflowAssignees.filter(user => selectedStep.assignees.includes(user.id)).map(user => (
<span key={user.id}>
<UserWithProfilePic
textSize="text-sm"
prefix={`- ${getUserTypeLabelShort(user.type)}`}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
)}
{selectedStepIndex === currentStepIndex && !selectedStep.completed && !selectedStep.rejected &&
<div className="flex flex-row gap-2 ">
<Button
type="submit"
color="purple"
variant="solid"
disabled={(!selectedStep.assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || isLoading}
onClick={handleApproveStep}
padding="px-6 py-2"
className="mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoMdCheckmarkCircleOutline size={20} />
Approve Step
</>
)}
</Button>
<Button
type="submit"
color="red"
variant="solid"
disabled={(!selectedStep.assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || isLoading}
onClick={handleRejectStep}
padding="px-6 py-2"
className="mb-3 w-1/2 text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<RxCrossCircled size={20} />
Reject
</>
)}
</Button>
</div>
}
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Input comments here"
className="w-full h-64 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
/>
<Button
type="submit"
color="purple"
variant="solid"
onClick={handleSaveComments}
disabled={isLoading}
padding="px-6 py-2"
className="mt-6 mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<FiSave size={20} />
Save Comments
</>
)}
</Button>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
</div>
</motion.div>
)}
</section>
</LayoutGroup>
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,412 @@
import Tip from "@/components/ApprovalWorkflows/Tip";
import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import { ApprovalWorkflow, EditableApprovalWorkflow } from "@/interfaces/approval.workflow";
import { Entity } from "@/interfaces/entity";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
import { getEntities } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getEntitiesUsers } from "@/utils/users.be";
import axios from "axios";
import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { BsChevronLeft, BsTrash } from "react-icons/bs";
import { FaRegClone } from "react-icons/fa6";
import { MdFormatListBulletedAdd } from "react-icons/md";
import { toast, ToastContainer } from "react-toastify";
import { v4 as uuidv4 } from 'uuid';
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user) || !["admin", "developer", "corporate", "mastercorporate"].includes(user.type))
return redirect("/")
const userEntitiesWithLabel = await getEntities(user.entities.map(entity => entity.id));
const allConfiguredWorkflows = await getApprovalWorkflowsByEntities("configured-workflows", userEntitiesWithLabel.map(entity => entity.id));
const approverTypes = ["teacher", "corporate", "mastercorporate"];
if (user.type === "developer") {
approverTypes.push("developer");
}
const userEntitiesApprovers = await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: { $in: approverTypes } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
return {
props: serialize({
user,
allConfiguredWorkflows,
userEntitiesWithLabel,
userEntitiesApprovers,
}),
};
}, sessionOptions);
interface Props {
user: User,
allConfiguredWorkflows: EditableApprovalWorkflow[],
userEntitiesWithLabel: Entity[],
userEntitiesApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}
export default function Home({ user, allConfiguredWorkflows, userEntitiesWithLabel, userEntitiesApprovers }: Props) {
const [workflows, setWorkflows] = useState<EditableApprovalWorkflow[]>(allConfiguredWorkflows);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | undefined>(undefined);
const [entityId, setEntityId] = useState<string | null | undefined>(null);
const [entityApprovers, setEntityApprovers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
const [entityAvailableFormIntakers, setEntityAvailableFormIntakers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
const [isAdding, setIsAdding] = useState<boolean>(false); // used to temporary timeout new workflow button. With animations, clicking too fast might cause state inconsistencies between renders.
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRedirecting, setIsRedirecting] = useState<boolean>(false);
const router = useRouter();
useEffect(() => {
if (entityId) {
setEntityApprovers(
userEntitiesApprovers.filter(approver =>
approver.entities.some(entity => entity.id === entityId)
)
);
}
}, [entityId, userEntitiesApprovers]);
useEffect(() => {
if (entityId) {
// Get all workflows for the selected entity
const workflowsForEntity = workflows.filter(wf => wf.entityId === entityId);
// For all workflows except the current one, collect the first step assignees
const assignedFormIntakers = workflowsForEntity.reduce<string[]>((acc, wf) => {
if (wf.id === selectedWorkflowId) return acc; // skip current workflow so its selection isnt removed
const formIntakeStep = wf.steps.find(step => step.stepType === "form-intake");
if (formIntakeStep) {
// Only consider non-null assignees
const validAssignees = formIntakeStep.assignees.filter(
(assignee): assignee is string => !!assignee
);
return acc.concat(validAssignees);
}
return acc;
}, []);
// Now filter out any user from entityApprovers whose id is in the assignedFormIntakers list.
// (The selected one in the current workflow is allowed even if it is in the list.)
const availableFormIntakers = entityApprovers.filter(assignee =>
!assignedFormIntakers.includes(assignee.id)
);
setEntityAvailableFormIntakers(availableFormIntakers);
}
}, [entityId, entityApprovers, workflows, selectedWorkflowId]);
const currentWorkflow = workflows.find(wf => wf.id === selectedWorkflowId);
const ENTITY_OPTIONS = userEntitiesWithLabel.map(entity => ({
label: entity.label,
value: entity.id,
filter: (x: EditableApprovalWorkflow) => x.entityId === entity.id,
}));
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
if (workflows.length === 0) {
setIsLoading(false);
return;
}
for (const workflow of workflows) {
for (const step of workflow.steps) {
if (step.assignees.every(x => !x)) {
toast.warning("There are empty steps in at least one of the configured workflows.");
setIsLoading(false);
return;
}
}
}
const filteredWorkflows: ApprovalWorkflow[] = workflows.map(workflow => ({
...workflow,
steps: workflow.steps.map(step => ({
...step,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
}))
}));
const requestData = { filteredWorkflows, userEntitiesWithLabel };
axios
.post(`/api/approval-workflows/create`, requestData)
.then(() => {
toast.success("Approval Workflows created successfully.");
setIsRedirecting(true);
router.push("/approval-workflows");
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
}
else if (reason.response.status === 403) {
toast.error("You do not have permission to create Approval Workflows!");
}
else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false);
console.log("Submitted Values:", filteredWorkflows);
return;
})
};
const handleAddNewWorkflow = () => {
if (isAdding) return;
setIsAdding(true);
const newId = uuidv4(); // this id is only used in UI. it is ommited on submission to DB and lets mongo handle unique id.
const newWorkflow: EditableApprovalWorkflow = {
id: newId,
name: "",
entityId: "",
modules: [],
requester: user.id,
startDate: Date.now(),
status: "pending",
steps: [
{ key: 9998, stepType: "form-intake", stepNumber: 1, completed: false, firstStep: true, finalStep: false, assignees: [null] },
{ key: 9999, stepType: "approval-by", stepNumber: 2, completed: false, firstStep: false, finalStep: true, assignees: [null] },
],
};
setWorkflows((prev) => [...prev, newWorkflow]);
handleSelectWorkflow(newId);
setTimeout(() => setIsAdding(false), 300);
};
const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
setWorkflows(prev =>
prev.map(wf => (wf.id === updatedWorkflow.id ? updatedWorkflow : wf))
);
}
const handleSelectWorkflow = (id: string | undefined) => {
setSelectedWorkflowId(id);
const selectedWorkflow = workflows.find(wf => wf.id === id);
if (selectedWorkflow) {
setEntityId(selectedWorkflow.entityId || null);
} else {
setEntityId(null);
}
};
const handleCloneWorkflow = (id: string) => {
const workflowToClone = workflows.find(wf => wf.id === id);
if (!workflowToClone) return;
const newId = uuidv4();
const clonedWorkflow: EditableApprovalWorkflow = {
...workflowToClone,
id: newId,
steps: workflowToClone.steps.map(step => ({
...step,
assignees: step.firstStep ? [null] : [...step.assignees], // we can't have more than one form intaker per teacher per entity
})),
};
setWorkflows(prev => {
const updatedWorkflows = [...prev, clonedWorkflow];
setSelectedWorkflowId(newId);
setEntityId(clonedWorkflow.entityId || null);
return updatedWorkflows;
});
};
const handleDeleteWorkflow = (id: string) => {
if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return;
const updatedWorkflows = workflows.filter(wf => wf.id !== id);
setWorkflows(updatedWorkflows);
if (selectedWorkflowId === id) {
handleSelectWorkflow(updatedWorkflows.find(wf => wf.id)?.id);
}
};
const handleEntityChange = (wf: EditableApprovalWorkflow, entityId: string) => {
const updatedWorkflow = {
...wf,
entityId: entityId,
steps: wf.steps.map(step => ({
...step,
assignees: step.assignees.map(() => null)
}))
}
onWorkflowChange(updatedWorkflow);
}
return (
<>
<Head>
<title> Configure Workflows | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<section className="flex flex-col">
<div className="flex items-center gap-2">
<Link
href="/approval-workflows"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h1 className="text-2xl font-semibold">{"Configure Approval Workflows"}</h1>
</div>
</section>
<Tip text="Setting a teacher as a Form Intaker means the configured workflow will be instantiated when said teacher publishes an exam. Only one Form Intake per teacher per entity is allowed." />
<section className="flex flex-row gap-6">
<Button
color="purple"
variant="solid"
onClick={handleAddNewWorkflow}
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
>
<MdFormatListBulletedAdd className="size-6" />
Add New Workflow
</Button>
{workflows.length !== 0 && <div className="bg-gray-300 w-[1px]"></div>}
<div className="flex flex-wrap gap-2">
{workflows.map((workflow) => (
<Button
key={workflow.id}
color="purple"
variant={
selectedWorkflowId === workflow.id
? "solid"
: "outline"
}
onClick={() => handleSelectWorkflow(workflow.id)}
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
>
{workflow.name.trim() === "" ? "Workflow" : workflow.name}
</Button>
))}
</div>
</section>
<form onSubmit={handleSubmit}>
{currentWorkflow && (
<>
<div className="mb-8 flex flex-row gap-6 items-end">
<Input
label="Name:"
type="text"
name={currentWorkflow.name}
placeholder="Enter workflow name"
value={currentWorkflow.name}
onChange={(updatedName) => {
const updatedWorkflow = {
...currentWorkflow,
name: updatedName,
};
onWorkflowChange(updatedWorkflow);
}}
/>
<Select
label="Entity:"
options={ENTITY_OPTIONS}
value={
currentWorkflow.entityId === ""
? null
: ENTITY_OPTIONS.find(option => option.value === currentWorkflow.entityId)
}
onChange={(selectedEntity) => {
if (currentWorkflow.entityId) {
if (!confirm("Clearing or changing entity will clear all the assignees for all steps in this workflow. Are you sure you want to proceed?")) return;
}
if (selectedEntity?.value) {
setEntityId(selectedEntity.value);
handleEntityChange(currentWorkflow, selectedEntity.value);
}
}}
isClearable
placeholder="Enter workflow entity"
/>
<Button
color="gray"
variant="solid"
onClick={() => handleCloneWorkflow(currentWorkflow.id)}
type="button"
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
>
Clone Workflow
<FaRegClone className="size-6" />
</Button>
<Button
color="red"
variant="solid"
onClick={() => handleDeleteWorkflow(currentWorkflow.id)}
type="button"
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
>
Delete Workflow
<BsTrash className="size-6" />
</Button>
</div>
<AnimatePresence mode="wait">
<LayoutGroup key={currentWorkflow.id}>
<motion.div
key="form"
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 60 }}
transition={{ duration: 0.20 }}
>
{(!currentWorkflow.name || !currentWorkflow.entityId) && (
<Tip text="Please fill in workflow name and associated entity to start configuring workflow." />
)}
<WorkflowForm
workflow={currentWorkflow}
onWorkflowChange={onWorkflowChange}
entityApprovers={entityApprovers}
entityAvailableFormIntakers={entityAvailableFormIntakers}
isLoading={isLoading}
isRedirecting={isRedirecting}
/>
</motion.div>
</LayoutGroup>
</AnimatePresence>
</>
)}
</form>
</>
);
}

View File

@@ -0,0 +1,417 @@
import Tip from "@/components/ApprovalWorkflows/Tip";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
import { Module, ModuleTypeLabels } from "@/interfaces";
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { getEntities } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getSpecificUsers } from "@/utils/users.be";
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import axios from "axios";
import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import { useEffect, useState } from "react";
import { BsTrash } from "react-icons/bs";
import { FaRegEdit } from "react-icons/fa";
import { IoIosAddCircleOutline } from "react-icons/io";
import { toast, ToastContainer } from "react-toastify";
const columnHelper = createColumnHelper<ApprovalWorkflow>();
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
return redirect("/")
const workflows = await getApprovalWorkflows("active-workflows");
const allAssigneeIds: string[] = [
...new Set(
workflows
.map(workflow => workflow.steps
.map(step => step.assignees)
.flat()
).flat()
)
];
return {
props: serialize({
user,
initialWorkflows: workflows,
workflowsAssignees: await getSpecificUsers(allAssigneeIds),
userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)),
}),
};
}, sessionOptions);
const StatusClassNames: { [key in ApprovalWorkflowStatus]: string } = {
approved: "bg-green-100 text-green-800 border border-green-300 before:content-[''] before:w-2 before:h-2 before:bg-green-500 before:rounded-full before:inline-block before:mr-2",
pending: "bg-orange-100 text-orange-800 border border-orange-300 before:content-[''] before:w-2 before:h-2 before:bg-orange-500 before:rounded-full before:inline-block before:mr-2",
rejected: "bg-red-100 text-red-800 border border-red-300 before:content-[''] before:w-2 before:h-2 before:bg-red-500 before:rounded-full before:inline-block before:mr-2",
};
type CustomStatus = ApprovalWorkflowStatus | undefined;
type CustomEntity = EntityWithRoles["id"] | undefined;
const STATUS_OPTIONS = [
{
label: "Approved",
value: "approved",
filter: (x: ApprovalWorkflow) => x.status === "approved",
},
{
label: "Pending",
value: "pending",
filter: (x: ApprovalWorkflow) => x.status === "pending",
},
{
label: "Rejected",
value: "rejected",
filter: (x: ApprovalWorkflow) => x.status === "rejected",
},
];
interface Props {
user: User,
initialWorkflows: ApprovalWorkflow[],
workflowsAssignees: User[],
userEntitiesWithLabel: Entity[],
}
export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) {
const { workflows, reload } = useApprovalWorkflows();
const currentWorkflows = workflows || initialWorkflows;
const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]);
const [statusFilter, setStatusFilter] = useState<CustomStatus>(undefined);
const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined);
const [nameFilter, setNameFilter] = useState<string>("");
const ENTITY_OPTIONS = [
...userEntitiesWithLabel
.map(entity => ({
label: entity.label,
value: entity.id,
filter: (x: ApprovalWorkflow) => x.entityId === entity.id,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
];
useEffect(() => {
const filters: Array<(workflow: ApprovalWorkflow) => boolean> = [];
if (statusFilter && statusFilter !== undefined) {
const statusOption = STATUS_OPTIONS.find((x) => x.value === statusFilter);
if (statusOption && statusOption.filter) {
filters.push(statusOption.filter);
}
}
if (entityFilter && entityFilter !== undefined) {
const entityOption = ENTITY_OPTIONS.find((x) => x.value === entityFilter);
if (entityOption && entityOption.filter) {
filters.push(entityOption.filter);
}
}
if (nameFilter.trim() !== "") {
const nameFilterFunction = (workflow: ApprovalWorkflow) =>
workflow.name.toLowerCase().includes(nameFilter.toLowerCase());
filters.push(nameFilterFunction);
}
// Apply all filters
const filtered = currentWorkflows.filter(workflow => filters.every(filterFn => filterFn(workflow)));
setFilteredWorkflows(filtered);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentWorkflows, statusFilter, entityFilter, nameFilter]);
const handleNameFilterChange = (name: ApprovalWorkflow["name"]) => {
setNameFilter(name);
};
const deleteApprovalWorkflow = (id: string | undefined, name: string) => {
if (id === undefined) return;
if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return;
axios
.delete(`/api/approval-workflows/${id}`)
.then(() => {
toast.success(`Successfully deleted ${name} Approval Workflow.`);
reload();
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Approval Workflow not found!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to delete an Approval Workflow!");
} else {
toast.error("Something went wrong, please try again later.");
}
return;
})
};
const columns = [
columnHelper.accessor("name", {
header: "EXAM NAME",
cell: (info) => (
<span className="font-medium">
{info.getValue()}
</span>
),
}),
columnHelper.accessor("modules", {
header: "MODULES",
cell: (info) => (
<div className="flex flex-wrap gap-2">
{info.getValue().map((module: Module, index: number) => (
<span
key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900"
>
{ModuleTypeLabels[module]}
</span>
))}
</div>
),
}),
columnHelper.accessor("status", {
header: "STATUS",
cell: (info) => (
<span className={clsx("inline-block rounded-full px-3 py-1 text-sm font-medium text-left w-[110px]", StatusClassNames[info.getValue()])}>
{ApprovalWorkflowStatusLabel[info.getValue()]}
</span>
),
}),
columnHelper.accessor("entityId", {
header: "ENTITY",
cell: (info) => (
<span className="font-medium">
{userEntitiesWithLabel.find((entity) => entity.id === info.getValue())?.label}
</span>
),
}),
columnHelper.accessor("steps", {
id: "currentAssignees",
header: "CURRENT ASSIGNEES",
cell: (info) => {
const steps = info.row.original.steps;
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
if (rejected) return "";
const assignees = currentStep?.assignees.map((assigneeId) => {
const assignee = workflowsAssignees.find((user) => user.id === assigneeId);
return assignee?.name || "Unknown Assignee";
});
return (
<div className="flex flex-wrap gap-2">
{assignees?.map((assigneeName: string, index: number) => (
<span
key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-gray-100 border border-gray-300 text-gray-900"
>
{assigneeName}
</span>
))}
</div>
);
},
}),
columnHelper.accessor("steps", {
id: "currentStep",
header: "CURRENT STEP",
cell: (info) => {
const steps = info.row.original.steps;
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
return (
<span className="font-medium">
{currentStep && !rejected
? `Step ${currentStep.stepNumber}: ${StepTypeLabel[currentStep.stepType]}`
: "Completed"}
</span>
);
},
}),
columnHelper.accessor("steps", {
header: "ACTIONS",
id: "actions",
cell: ({ row }) => {
const steps = row.original.steps;
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
return (
<div className="flex gap-4">
<button
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={(e) => {
e.stopPropagation();
deleteApprovalWorkflow(row.original._id?.toString(), row.original.name);
}}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{currentStep && !rejected && (
<Link
onClick={(e) => e.stopPropagation()}
data-tip="Edit"
href={`/approval-workflows/${row.original._id?.toString()}/edit`}
className="cursor-pointer tooltip"
>
<FaRegEdit className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</Link>
)}
</div>
);
},
})
];
const table = useReactTable({
data: filteredWorkflows,
columns: columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<Head>
<title>Approval Workflows Panel | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<h1 className="text-2xl font-semibold">Approval Workflows</h1>
<div className="flex flex-row">
<Link href={"/approval-workflows/create"}>
<Button
color="purple"
variant="solid"
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
>
<IoIosAddCircleOutline className="size-6" />
Configure Workflows
</Button>
</Link>
</div>
<div className="flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Name</label>
<Input
name="nameFilter"
type="text"
value={nameFilter}
onChange={handleNameFilterChange}
placeholder="Filter by name..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Status</label>
<Select
options={STATUS_OPTIONS}
value={STATUS_OPTIONS.find((x) => x.value === statusFilter)}
onChange={(value) => setStatusFilter((value?.value as ApprovalWorkflowStatus) ?? undefined)}
isClearable
placeholder="Filter by status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Entity</label>
<Select
options={ENTITY_OPTIONS}
value={ENTITY_OPTIONS.find((x) => x.value === entityFilter)}
onChange={(value) => setEntityFilter((value?.value as CustomEntity) ?? undefined)}
isClearable
placeholder="Filter by entity..."
/>
</div>
</div>
<Tip text="An exam submission will instantiate the approval workflow configured for the exam author. The exam will be valid only when all the steps of the workflow have been approved."></Tip>
<div className="px-6 pb-4 bg-mti-purple-ultralight rounded-2xl border-2 border-mti-purple-light border-opacity-40">
<table
className="w-full table-auto border-separate border-spacing-y-2"
style={{ tableLayout: "auto" }}
>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-3 py-2 text-left text-mti-purple-ultradark">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
onClick={() => window.location.href = `/approval-workflows/${row.original._id?.toString()}`}
style={{ cursor: "pointer" }}
className="bg-purple-50"
>
{row.getVisibleCells().map((cell, cellIndex) => {
const lastCellIndex = row.getVisibleCells().length - 1;
let cellClasses = "pl-3 pr-4 py-2 border-y-2 border-mti-purple-light border-opacity-60";
if (cellIndex === 0) {
cellClasses += " border-l-2 rounded-l-2xl";
}
if (cellIndex === lastCellIndex) {
cellClasses += " border-r-2 rounded-r-2xl";
}
return (
<td key={cellIndex} className={cellClasses}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

View File

@@ -1,104 +1,178 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers";
import { Grading, Module } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { getExamById } from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { calculateBandScore, getGradingLabel } from "@/utils/score"; import { calculateBandScore, getGradingLabel } from "@/utils/score";
import { convertToUserSolutions } from "@/utils/stats";
import { getUserName } from "@/utils/users"; import { getUserName } from "@/utils/users";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize, uniqBy } from "lodash"; import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { BsBook, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; import {
BsBook,
BsBuilding,
BsChevronLeft,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { futureAssignmentFilter } from "@/utils/assignments"; import { futureAssignmentFilter } from "@/utils/assignments";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { checkAccess, doesEntityAllow } from "@/utils/permissions"; import { checkAccess, doesEntityAllow } from "@/utils/permissions";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getAssignment } from "@/utils/assignments.be"; import { getAssignment } from "@/utils/assignments.be";
import { getEntitiesUsers, getEntityUsers, getUsers } from "@/utils/users.be"; import { getEntityUsers, getUsers } from "@/utils/users.be";
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be"; import { getEntityWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities, getGroupsByEntity } from "@/utils/groups.be";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Head from "next/head"; import Head from "next/head";
import Layout from "@/components/High/Layout";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import Link from "next/link"; import Link from "next/link";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { getGradingSystemByEntity } from "@/utils/grading.be"; import { getGradingSystemByEntity } from "@/utils/grading.be";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) if (
return redirect("/assignments") !checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/assignments");
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); res.setHeader(
"Cache-Control",
"public, s-maxage=10, stale-while-revalidate=59"
);
const { id } = params as { id: string }; const { id } = params as { id: string };
const assignment = await getAssignment(id); const assignment = await getAssignment(id);
if (!assignment) return redirect("/assignments") if (!assignment) return redirect("/assignments");
const entity = await getEntityWithRoles(assignment.entity || "") const entity = await getEntityWithRoles(assignment.entity || "");
if (!entity) { if (!entity) {
const users = await getUsers() const users = await getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
}
);
return { props: serialize({ user, users, assignment }) }; return { props: serialize({ user, users, assignment }) };
} }
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") if (!doesEntityAllow(user, entity, "view_assignments"))
return redirect("/assignments");
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); const [users, gradingSystem] = await Promise.all([
const gradingSystem = await getGradingSystemByEntity(entity.id) await (checkAccess(user, ["developer", "admin"])
? getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
}
)
: getEntityUsers(
entity.id,
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
}
)),
getGradingSystemByEntity(entity.id),
]);
return { props: serialize({ user, users, entity, assignment, gradingSystem }) }; return {
}, sessionOptions); props: serialize({ user, users, entity, assignment, gradingSystem }),
};
},
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
assignment: Assignment; assignment: Assignment;
entity?: EntityWithRoles entity?: EntityWithRoles;
gradingSystem?: Grading gradingSystem?: Grading;
} }
export default function AssignmentView({ user, users, entity, assignment, gradingSystem }: Props) { export default function AssignmentView({
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment') user,
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment') users,
entity,
assignment,
gradingSystem,
}: Props) {
const canDeleteAssignment = useEntityPermission(
user,
entity,
"delete_assignment"
);
const canStartAssignment = useEntityPermission(
user,
entity,
"start_assignment"
);
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
const deleteAssignment = async () => { const deleteAssignment = async () => {
if (!canDeleteAssignment) return if (!canDeleteAssignment) return;
if (!confirm("Are you sure you want to delete this assignment?")) return; if (!confirm("Are you sure you want to delete this assignment?")) return;
axios axios
.delete(`/api/assignments/${assignment?.id}`) .delete(`/api/assignments/${assignment?.id}`)
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`)) .then(() =>
toast.success(
`Successfully deleted the assignment "${assignment?.name}".`
)
)
.catch(() => toast.error("Something went wrong, please try again later.")) .catch(() => toast.error("Something went wrong, please try again later."))
.finally(() => router.push("/assignments")); .finally(() => router.push("/assignments"));
}; };
const startAssignment = () => { const startAssignment = () => {
if (!canStartAssignment) return if (!canStartAssignment) return;
if (!confirm("Are you sure you want to start this assignment?")) return; if (!confirm("Are you sure you want to start this assignment?")) return;
axios axios
.post(`/api/assignments/${assignment.id}/start`) .post(`/api/assignments/${assignment.id}/start`)
.then(() => { .then(() => {
toast.success(`The assignment "${assignment.name}" has been started successfully!`); toast.success(
`The assignment "${assignment.name}" has been started successfully!`
);
router.replace(router.asPath); router.replace(router.asPath);
}) })
.catch((e) => { .catch((e) => {
@@ -120,15 +194,26 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
const resultModuleBandScores = assignment.results.map((r) => { const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module); const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); const correct = moduleStats.reduce(
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); (acc, curr) => acc + curr.score.correct,
0
);
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0
);
return calculateBandScore(correct, total, module, r.type); return calculateBandScore(correct, total, module, r.type);
}); });
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; return resultModuleBandScores.length === 0
? -1
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
}; };
const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => { const aggregateScoresByModule = (
stats: Stat[]
): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: { const scores: {
[key in Module]: { total: number; missing: number; correct: number }; [key in Module]: { total: number; missing: number; correct: number };
} = { } = {
@@ -159,7 +244,9 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
}, },
}; };
stats.filter(x => !x.isPractice).forEach((x) => { stats
.filter((x) => !x.isPractice)
.forEach((x) => {
scores[x.module!] = { scores[x.module!] = {
total: scores[x.module!].total + x.score.total, total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct, correct: scores[x.module!].correct + x.score.correct,
@@ -172,28 +259,53 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
.map((x) => ({ module: x as Module, ...scores[x as Module] })); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const levelAverage = (aggregatedLevels: { module: Module, level: number }[]) => const levelAverage = (
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length aggregatedLevels: { module: Module; level: number }[]
) =>
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0
) / aggregatedLevels.length;
const renderLevelScore = (stats: Stat[], aggregatedLevels: { module: Module, level: number }[]) => { const renderLevelScore = (
const defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1) stats: Stat[],
if (!stats.every(s => s.module === "level")) return defaultLevelScore aggregatedLevels: { module: Module; level: number }[]
if (!gradingSystem) return defaultLevelScore ) => {
const defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1);
if (!stats.every((s) => s.module === "level")) return defaultLevelScore;
if (!gradingSystem) return defaultLevelScore;
const score = { const score = {
correct: stats.reduce((acc, curr) => acc + curr.score.correct, 0), correct: stats.reduce((acc, curr) => acc + curr.score.correct, 0),
total: stats.reduce((acc, curr) => acc + curr.score.total, 0) total: stats.reduce((acc, curr) => acc + curr.score.total, 0),
} };
const level: number = calculateBandScore(score.correct, score.total, "level", user.focus); const level: number = calculateBandScore(
score.correct,
score.total,
"level",
user.focus
);
return getGradingLabel(level, gradingSystem.steps) return getGradingLabel(level, gradingSystem.steps);
} };
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { const customContent = (
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); stats: Stat[],
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); user: string,
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); focus: "academic" | "general"
) => {
const correct = stats.reduce(
(accumulator, current) => accumulator + current.score.correct,
0
);
const total = stats.reduce(
(accumulator, current) => accumulator + current.score.total,
0
);
const aggregatedScores = aggregateScoresByModule(stats).filter(
(x) => x.total > 0
);
const aggregatedLevels = aggregatedScores.map((x) => ({ const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module, module: x.module,
@@ -203,19 +315,22 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
const timeSpent = stats[0].timeSpent; const timeSpent = stats[0].timeSpent;
const selectExam = () => { const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam)); const examPromises = uniqBy(stats, "exam").map((stat) =>
getExamById(stat.module, stat.exam)
);
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
dispatch({ dispatch({
type: 'INIT_SOLUTIONS', payload: { type: "INIT_SOLUTIONS",
payload: {
exams: exams.map((x) => x!).sort(sortByModule), exams: exams.map((x) => x!).sort(sortByModule),
modules: exams modules: exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
stats, stats,
} },
}); });
router.push("/exam"); router.push("/exam");
} }
@@ -226,11 +341,15 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
<> <>
<div className="-md:items-center flex w-full justify-between 2xl:items-center"> <div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2"> <div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span> <span className="font-medium">
{formatTimestamp(stats[0].date.toString())}
</span>
{timeSpent && ( {timeSpent && (
<> <>
<span className="md:hidden 2xl:flex"> </span> <span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span> <span className="text-sm">
{Math.floor(timeSpent / 60)} minutes
</span>
</> </>
)} )}
</div> </div>
@@ -238,10 +357,10 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
className={clsx( className={clsx(
correct / total >= 0.7 && "text-mti-purple", correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose", correct / total < 0.3 && "text-mti-rose"
)}> )}
Level{' '} >
{renderLevelScore(stats, aggregatedLevels)} Level {renderLevelScore(stats, aggregatedLevels)}
</span> </span>
</div> </div>
@@ -256,8 +375,9 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
module === "listening" && "bg-ielts-listening", module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level", module === "level" && "bg-ielts-level"
)}> )}
>
{module === "reading" && <BsBook className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
@@ -284,11 +404,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
className={clsx( className={clsx(
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out", "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", correct / total >= 0.3 &&
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose"
)} )}
onClick={selectExam} onClick={selectExam}
role="button"> role="button"
>
{content} {content}
</div> </div>
<div <div
@@ -296,11 +419,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
className={clsx( className={clsx(
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden", "border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", correct / total >= 0.3 &&
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose"
)} )}
data-tip="Your screen size is too small to view previous exams." data-tip="Your screen size is too small to view previous exams."
role="button"> role="button"
>
{content} {content}
</div> </div>
</div> </div>
@@ -318,29 +444,46 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
}; };
const removeInactiveAssignees = () => { const removeInactiveAssignees = () => {
const mappedResults = mapBy(assignment.results, 'user') const mappedResults = mapBy(assignment.results, "user");
const inactiveAssignees = assignment.assignees.filter((a) => !mappedResults.includes(a)) const inactiveAssignees = assignment.assignees.filter(
const activeAssignees = assignment.assignees.filter((a) => mappedResults.includes(a)) (a) => !mappedResults.includes(a)
);
const activeAssignees = assignment.assignees.filter((a) =>
mappedResults.includes(a)
);
if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return if (
!confirm(
`Are you sure you want to remove ${inactiveAssignees.length} assignees?`
)
)
return;
axios axios
.patch(`/api/assignments/${assignment.id}`, { assignees: activeAssignees }) .patch(`/api/assignments/${assignment.id}`, {
assignees: activeAssignees,
})
.then(() => { .then(() => {
toast.success(`The assignment "${assignment.name}" has been updated successfully!`); toast.success(
`The assignment "${assignment.name}" has been updated successfully!`
);
router.replace(router.asPath); router.replace(router.asPath);
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
}); });
} };
const copyLink = async () => { const copyLink = async () => {
const origin = window.location.origin const origin = window.location.origin;
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`) await navigator.clipboard.writeText(
toast.success("The URL to the assignment has been copied to your clipboard!") `${origin}/exam?assignment=${assignment.id}`
} );
toast.success(
"The URL to the assignment has been copied to your clipboard!"
);
};
return ( return (
<> <>
@@ -353,11 +496,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between"> <div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/assignments"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">{assignment.name}</h2> <h2 className="font-bold text-2xl">{assignment.name}</h2>
@@ -376,16 +522,28 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6" className="h-6"
textClassName={ textClassName={
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 (assignment?.results.length || 0) /
(assignment?.assignees.length || 1) <
0.5
? "!text-mti-gray-dim font-light" ? "!text-mti-gray-dim font-light"
: "text-white" : "text-white"
} }
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100} percentage={
((assignment?.results.length || 0) /
(assignment?.assignees.length || 1)) *
100
}
/> />
<div className="flex items-start gap-8"> <div className="flex items-start gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span> <span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span> Start Date:{" "}
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>
End Date:{" "}
{moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
</span>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span> <span>
@@ -395,12 +553,18 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
.map((u) => `${u.name} (${u.email})`) .map((u) => `${u.name} (${u.email})`)
.join(", ")} .join(", ")}
</span> </span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span> <span>
Assigner:{" "}
{getUserName(users.find((x) => x.id === assignment?.assigner))}
</span>
</div> </div>
</div> </div>
{assignment.assignees.length !== 0 && assignment.results.length !== assignment.assignees.length && ( {assignment.assignees.length !== 0 &&
<Button onClick={removeInactiveAssignees} variant="outline">Remove Inactive Assignees</Button> assignment.results.length !== assignment.assignees.length && (
<Button onClick={removeInactiveAssignees} variant="outline">
Remove Inactive Assignees
</Button>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -417,15 +581,22 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
module === "listening" && "bg-ielts-listening", module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level", module === "level" && "bg-ielts-level"
)}> )}
>
{module === "reading" && <BsBook className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />} {module === "listening" && (
<BsHeadphones className="h-4 w-4" />
)}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} {module === "speaking" && (
<BsMegaphone className="h-4 w-4" />
)}
{module === "level" && <BsClipboard className="h-4 w-4" />} {module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && ( {calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span> <span className="text-sm">
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)} )}
</div> </div>
))} ))}
@@ -433,40 +604,64 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xl font-bold"> <span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length}) Results ({assignment?.results.length}/
{assignment?.assignees.length})
</span> </span>
<div> <div>
{assignment && assignment?.results.length > 0 && ( {assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6"> <div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))} {assignment.results.map((r) =>
customContent(r.stats, r.user, r.type)
)}
</div> </div>
)} )}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>} {assignment && assignment?.results.length === 0 && (
<span className="ml-1 font-semibold">No results yet...</span>
)}
</div> </div>
</div> </div>
<div className="flex gap-4 w-full items-center justify-end"> <div className="flex gap-4 w-full items-center justify-end">
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}> <Button
variant="outline"
color="purple"
className="w-full max-w-[200px]"
onClick={copyLink}
>
Copy Link Copy Link
</Button> </Button>
{assignment && {assignment &&
(assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( (assignment.results.length === assignment.assignees.length ||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}> moment().isAfter(moment(assignment.endDate))) && (
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={deleteAssignment}
>
Delete Delete
</Button> </Button>
)} )}
{/** if the assignment is not deemed as active yet, display start */} {/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && ( {shouldRenderStart() && (
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}> <Button
variant="outline"
color="green"
className="w-full max-w-[200px]"
onClick={startAssignment}
>
Start Start
</Button> </Button>
)} )}
<Button onClick={() => router.push("/assignments")} className="w-full max-w-[200px]"> <Button
onClick={() => router.push("/assignments")}
className="w-full max-w-[200px]"
>
Close Close
</Button> </Button>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
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 Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
@@ -19,7 +18,11 @@ import { requestUser } from "@/utils/api";
import { getAssignment } from "@/utils/assignments.be"; import { getAssignment } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, doesEntityAllow, findAllowedEntities } from "@/utils/permissions"; import {
checkAccess,
doesEntityAllow,
findAllowedEntities,
} from "@/utils/permissions";
import { calculateAverageLevel } from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
@@ -33,34 +36,91 @@ import { useRouter } from "next/router";
import { generate } from "random-words"; import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs"; import {
BsBook,
BsCheckCircle,
BsChevronLeft,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); res.setHeader(
"Cache-Control",
"public, s-maxage=10, stale-while-revalidate=59"
);
const { id } = params as { id: string }; const { id } = params as { id: string };
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const isAdmin = checkAccess(user, ["developer", "admin"]);
const assignment = await getAssignment(id); const [assignment, entities] = await Promise.all([
if (!assignment) return redirect("/assignments") getAssignment(id),
getEntitiesWithRoles(isAdmin ? undefined : entityIDS),
]);
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); if (!assignment) return redirect("/assignments");
const entity = entities.find((e) => e.id === assignment.entity) const entity = entities.find((e) => e.id === assignment.entity);
if (!entity) return redirect("/assignments") if (!entity) return redirect("/assignments");
if (!doesEntityAllow(user, entity, 'edit_assignment')) return redirect("/assignments")
const allowedEntities = findAllowedEntities(user, entities, 'edit_assignment') if (!doesEntityAllow(user, entity, "edit_assignment"))
return redirect("/assignments");
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); const allowedEntities = findAllowedEntities(
const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); user,
entities,
"edit_assignment"
);
return { props: serialize({ user, users, entities: allowedEntities, assignment, groups }) }; const allowEntitiesIDs = mapBy(allowedEntities, "id");
}, sessionOptions);
const [users, groups] = await Promise.all([
isAdmin
? getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}
)
: getEntitiesUsers(allowEntitiesIDs, {}, 0, {
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}),
isAdmin ? getGroups() : getGroupsByEntities(allowEntitiesIDs),
]);
return {
props: serialize({
user,
users,
entities: allowedEntities,
assignment,
groups,
}),
};
},
sessionOptions
);
interface Props { interface Props {
assignment: Assignment; assignment: Assignment;
@@ -72,24 +132,44 @@ interface Props {
const SIZE = 9; const SIZE = 9;
export default function AssignmentsPage({ assignment, user, users, entities, groups }: Props) { export default function AssignmentsPage({
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment.exams.map((e) => e.module)); assignment,
user,
users,
entities,
groups,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(
assignment.exams.map((e) => e.module)
);
const [assignees, setAssignees] = useState<string[]>(assignment.assignees); const [assignees, setAssignees] = useState<string[]>(assignment.assignees);
const [teachers, setTeachers] = useState<string[]>(assignment.teachers || []); const [teachers, setTeachers] = useState<string[]>(assignment.teachers || []);
const [entity, setEntity] = useState<string | undefined>(assignment.entity || entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(
assignment.entity || entities[0]?.id
);
const [name, setName] = useState(assignment.name); const [name, setName] = useState(assignment.name);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(moment(assignment.startDate).toDate()); const [startDate, setStartDate] = useState<Date | null>(
const [endDate, setEndDate] = useState<Date | null>(moment(assignment.endDate).toDate()); moment(assignment.startDate).toDate()
);
const [endDate, setEndDate] = useState<Date | null>(
moment(assignment.endDate).toDate()
);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied"); const [instructorGender, setInstructorGender] = useState<InstructorGender>(
assignment?.instructorGender || "varied"
);
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [released, setReleased] = useState<boolean>(assignment.released || false); const [released, setReleased] = useState<boolean>(
assignment.released || false
);
const [autoStart, setAutostart] = useState<boolean>(assignment.autoStart || false); const [autoStart, setAutostart] = useState<boolean>(
assignment.autoStart || false
);
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]); const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
@@ -97,19 +177,34 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
const { exams } = useExams(); const { exams } = useExams();
const router = useRouter(); const router = useRouter();
const classrooms = useMemo(() => groups.filter((e) => e.entity === entity), [entity, groups]); const classrooms = useMemo(
() => groups.filter((e) => e.entity === entity),
[entity, groups]
);
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]); const userStudents = useMemo(
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]); () => users.filter((x) => x.type === "student"),
[users]
);
const userTeachers = useMemo(
() => users.filter((x) => x.type === "teacher"),
[users]
);
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } = useListSearch([["name"], ["email"]], userStudents); const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers); useListSearch([["name"], ["email"]], userStudents);
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
useListSearch([["name"], ["email"]], userTeachers);
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE); const { items: studentRows, renderMinimal: renderStudentPagination } =
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE); usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
usePagination(filteredTeachersRows, SIZE);
useEffect(() => { useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]); }, [selectedModules]);
useEffect(() => { useEffect(() => {
@@ -119,21 +214,33 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module]
);
}; };
const toggleAssignee = (user: User) => { const toggleAssignee = (user: User) => {
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setAssignees((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const toggleTeacher = (user: User) => { const toggleTeacher = (user: User) => {
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setTeachers((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const createAssignment = () => { const createAssignment = () => {
setIsLoading(true); setIsLoading(true);
(assignment ? axios.patch : axios.post)(`/api/assignments/${assignment.id}`, { (assignment ? axios.patch : axios.post)(
`/api/assignments/${assignment.id}`,
{
assignees, assignees,
name, name,
startDate, startDate,
@@ -147,9 +254,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
instructorGender, instructorGender,
released, released,
autoStart, autoStart,
}) }
)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been updated successfully!`); toast.success(
`The assignment "${name}" has been updated successfully!`
);
router.push(`/assignments/${assignment.id}`); router.push(`/assignments/${assignment.id}`);
}) })
.catch((e) => { .catch((e) => {
@@ -160,14 +270,21 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
}; };
const deleteAssignment = () => { const deleteAssignment = () => {
if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return; if (
!confirm(
`Are you sure you want to delete the "${assignment.name}" assignment?`
)
)
return;
console.log("GOT HERE"); console.log("GOT HERE");
setIsLoading(true); setIsLoading(true);
axios axios
.delete(`/api/assignments/${assignment.id}`) .delete(`/api/assignments/${assignment.id}`)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been deleted successfully!`); toast.success(
`The assignment "${name}" has been deleted successfully!`
);
router.push("/assignments"); router.push("/assignments");
}) })
.catch((e) => { .catch((e) => {
@@ -184,7 +301,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
axios axios
.post(`/api/assignments/${assignment.id}/start`) .post(`/api/assignments/${assignment.id}/start`)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been started successfully!`); toast.success(
`The assignment "${name}" has been started successfully!`
);
router.push(`/assignments/${assignment.id}`); router.push(`/assignments/${assignment.id}`);
}) })
.catch((e) => { .catch((e) => {
@@ -196,10 +315,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
}; };
const copyLink = async () => { const copyLink = async () => {
const origin = window.location.origin const origin = window.location.origin;
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`) await navigator.clipboard.writeText(
toast.success("The URL to the assignment has been copied to your clipboard!") `${origin}/exam?assignment=${assignment.id}`
} );
toast.success(
"The URL to the assignment has been copied to your clipboard!"
);
};
return ( return (
<> <>
@@ -212,10 +335,13 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/assignments"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Edit {assignment.name}</h2> <h2 className="font-bold text-2xl">Edit {assignment.name}</h2>
@@ -225,109 +351,180 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8"> <section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" /> <BsBook className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Reading</span> <span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && ( {!selectedModules.includes("reading") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("reading") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" /> <BsHeadphones className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Listening</span> <span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && ( {!selectedModules.includes("listening") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("listening") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={ onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level") (!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level") ? () => toggleModule("level")
: undefined : undefined
} }
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("level")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" /> <BsClipboard className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Level</span> <span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && ( {!selectedModules.includes("level") &&
selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />} {!selectedModules.includes("level") &&
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("level") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" /> <BsPen className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Writing</span> <span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && ( {!selectedModules.includes("writing") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("writing") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" /> <BsMegaphone className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Speaking</span> <span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( {!selectedModules.includes("speaking") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
</section> </section>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required /> <Input
type="text"
name="name"
onChange={(e) => setName(e)}
defaultValue={name}
label="Assignment Name"
required
/>
<Select <Select
label="Entity" label="Entity"
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(v) => setEntity(v ? v.value! : undefined)} onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{ value: entities[0]?.id, label: entities[0]?.label }} defaultValue={{
value: entities[0]?.id,
label: entities[0]?.label,
}}
/> />
</div> </div>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label> <label className="font-normal text-base text-mti-gray-dim">
Limit Start Date *
</label>
<ReactDatePicker <ReactDatePicker
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 z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())} filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -338,12 +535,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label> <label className="font-normal text-base text-mti-gray-dim">
End Date *
</label>
<ReactDatePicker <ReactDatePicker
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 z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)} filterTime={(date) => moment(date).isAfter(startDate)}
@@ -357,13 +556,19 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select <Select
value={{ value={{
value: instructorGender, value: instructorGender,
label: capitalize(instructorGender), label: capitalize(instructorGender),
}} }}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} onChange={(value) =>
value
? setInstructorGender(value.value as InstructorGender)
: null
}
disabled={!selectedModules.includes("speaking") || !!assignment} disabled={!selectedModules.includes("speaking") || !!assignment}
options={[ options={[
{ value: "male", label: "Male" }, { value: "male", label: "Male" },
@@ -383,11 +588,16 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<div className="grid md:grid-cols-2 w-full gap-4"> <div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => ( {selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full"> <div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label> <label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select <Select
value={{ value={{
value: examIDs.find((e) => e.module === module)?.id || null, value:
label: examIDs.find((e) => e.module === module)?.id || "", examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}} }}
onChange={(value) => onChange={(value) =>
value value
@@ -395,7 +605,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
...prev.filter((x) => x.module !== module), ...prev.filter((x) => x.module !== module),
{ id: value.value!, module }, { id: value.value!, module },
]) ])
: setExamIDs((prev) => prev.filter((x) => x.module !== module)) : setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
} }
options={exams options={exams
.filter((x) => !x.isDiagnostic && x.module === module) .filter((x) => !x.isDiagnostic && x.module === module)
@@ -409,25 +621,40 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
)} )}
<section className="w-full flex flex-col gap-4"> <section className="w-full flex flex-col gap-4">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">
Assignees ({assignees.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>((acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
}, []);
if (groupStudentIds.every((u) => assignees.includes(u))) { if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -445,9 +672,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", assignees.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -473,25 +703,43 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span> <span className="font-semibold">
Teachers ({teachers.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>(
(acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
},
[]
);
if (groupStudentIds.every((u) => teachers.includes(u))) { if (groupStudentIds.every((u) => teachers.includes(u))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setTeachers((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setTeachers((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -509,9 +757,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", teachers.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -530,21 +781,40 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
)} )}
<div className="flex gap-4 w-full items-end"> <div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}> <Checkbox
isChecked={variant === "full"}
onChange={() =>
setVariant((prev) => (prev === "full" ? "partial" : "full"))
}
>
Full length exams Full length exams
</Checkbox> </Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}> <Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams Generate different exams
</Checkbox> </Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}> <Checkbox
isChecked={released}
onChange={() => setReleased((d) => !d)}
>
Auto release results Auto release results
</Checkbox> </Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}> <Checkbox
isChecked={autoStart}
onChange={() => setAutostart((d) => !d)}
>
Auto start exam Auto start exam
</Checkbox> </Checkbox>
</div> </div>
<div className="flex gap-4 w-full justify-end"> <div className="flex gap-4 w-full justify-end">
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}> <Button
variant="outline"
color="purple"
className="w-full max-w-[200px]"
onClick={copyLink}
>
Copy Link Copy Link
</Button> </Button>
<Button <Button
@@ -552,7 +822,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline" variant="outline"
onClick={() => router.push("/assignments")} onClick={() => router.push("/assignments")}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}> isLoading={isLoading}
>
Cancel Cancel
</Button> </Button>
<Button <Button
@@ -561,7 +832,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline" variant="outline"
onClick={startAssignment} onClick={startAssignment}
disabled={isLoading || moment().isAfter(startDate)} disabled={isLoading || moment().isAfter(startDate)}
isLoading={isLoading}> isLoading={isLoading}
>
Start Start
</Button> </Button>
<Button <Button
@@ -570,7 +842,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline" variant="outline"
onClick={deleteAssignment} onClick={deleteAssignment}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}> isLoading={isLoading}
>
Delete Delete
</Button> </Button>
<Button <Button
@@ -584,12 +857,13 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
} }
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}
>
Update Update
</Button> </Button>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
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 Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
@@ -33,21 +32,61 @@ import { useRouter } from "next/router";
import { generate } from "random-words"; import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs"; import {
BsBook,
BsCheckCircle,
BsChevronLeft,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); const entities = await (checkAccess(user, ["developer", "admin"])
? getEntitiesWithRoles()
: getEntitiesWithRoles(entityIDS));
const allowedEntities = findAllowedEntities(user, entities, 'create_assignment') const allowedEntities = findAllowedEntities(
if (allowedEntities.length === 0) return redirect("/assignments") user,
entities,
"create_assignment"
);
if (allowedEntities.length === 0) return redirect("/assignments");
const users = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); const [users, groups] = await Promise.all([
const groups = await (isAdmin(user) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); isAdmin(user)
? getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}
)
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}),
isAdmin(user)
? getGroups()
: getGroupsByEntities(mapBy(allowedEntities, "id")),
]);
return { props: serialize({ user, users, entities, groups }) }; return { props: serialize({ user, users, entities, groups }) };
}, sessionOptions); }, sessionOptions);
@@ -62,10 +101,17 @@ interface Props {
const SIZE = 9; const SIZE = 9;
export default function AssignmentsPage({ user, users, groups, entities }: Props) { export default function AssignmentsPage({
user,
users,
groups,
entities,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [assignees, setAssignees] = useState<string[]>([]); const [assignees, setAssignees] = useState<string[]>([]);
const [teachers, setTeachers] = useState<string[]>([...(user.type === "teacher" ? [user.id] : [])]); const [teachers, setTeachers] = useState<string[]>([
...(user.type === "teacher" ? [user.id] : []),
]);
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const [name, setName] = useState( const [name, setName] = useState(
generate({ generate({
@@ -75,14 +121,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
max: 3, max: 3,
join: " ", join: " ",
formatter: capitalize, formatter: capitalize,
}), })
); );
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(moment().add(1, "hour").toDate()); const [startDate, setStartDate] = useState<Date | null>(
moment().add(1, "hour").toDate()
);
const [endDate, setEndDate] = useState<Date | null>(moment().hours(23).minutes(59).add(8, "day").toDate()); const [endDate, setEndDate] = useState<Date | null>(
moment().hours(23).minutes(59).add(8, "day").toDate()
);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>("varied"); const [instructorGender, setInstructorGender] =
useState<InstructorGender>("varied");
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [released, setReleased] = useState<boolean>(false); const [released, setReleased] = useState<boolean>(false);
@@ -95,20 +146,38 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
const { exams } = useExams(); const { exams } = useExams();
const router = useRouter(); const router = useRouter();
const classrooms = useMemo(() => groups.filter((e) => e.entity?.id === entity), [entity, groups]); const classrooms = useMemo(
const allowedUsers = useMemo(() => users.filter((u) => mapBy(u.entities, 'id').includes(entity || "")), [users, entity]) () => groups.filter((e) => e.entity?.id === entity),
[entity, groups]
);
const allowedUsers = useMemo(
() => users.filter((u) => mapBy(u.entities, "id").includes(entity || "")),
[users, entity]
);
const userStudents = useMemo(() => allowedUsers.filter((x) => x.type === "student"), [allowedUsers]); const userStudents = useMemo(
const userTeachers = useMemo(() => allowedUsers.filter((x) => x.type === "teacher"), [allowedUsers]); () => allowedUsers.filter((x) => x.type === "student"),
[allowedUsers]
);
const userTeachers = useMemo(
() => allowedUsers.filter((x) => x.type === "teacher"),
[allowedUsers]
);
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } = useListSearch([["name"], ["email"]], userStudents); const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers); useListSearch([["name"], ["email"]], userStudents);
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
useListSearch([["name"], ["email"]], userTeachers);
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE); const { items: studentRows, renderMinimal: renderStudentPagination } =
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE); usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
usePagination(filteredTeachersRows, SIZE);
useEffect(() => { useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]); }, [selectedModules]);
useEffect(() => { useEffect(() => {
@@ -118,15 +187,25 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module]
);
}; };
const toggleAssignee = (user: User) => { const toggleAssignee = (user: User) => {
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setAssignees((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const toggleTeacher = (user: User) => { const toggleTeacher = (user: User) => {
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setTeachers((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const createAssignment = () => { const createAssignment = () => {
@@ -149,7 +228,9 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
autoStart, autoStart,
}) })
.then((result) => { .then((result) => {
toast.success(`The assignment "${name}" has been created successfully!`); toast.success(
`The assignment "${name}" has been created successfully!`
);
router.push(`/assignments/${result.data.id}`); router.push(`/assignments/${result.data.id}`);
}) })
.catch((e) => { .catch((e) => {
@@ -170,10 +251,13 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/assignments"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Create Assignment</h2> <h2 className="font-bold text-2xl">Create Assignment</h2>
@@ -183,109 +267,180 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8"> <section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" /> <BsBook className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Reading</span> <span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && ( {!selectedModules.includes("reading") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("reading") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" /> <BsHeadphones className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Listening</span> <span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && ( {!selectedModules.includes("listening") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("listening") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={ onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level") (!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level") ? () => toggleModule("level")
: undefined : undefined
} }
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("level")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" /> <BsClipboard className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Level</span> <span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && ( {!selectedModules.includes("level") &&
selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />} {!selectedModules.includes("level") &&
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("level") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" /> <BsPen className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Writing</span> <span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && ( {!selectedModules.includes("writing") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("writing") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" /> <BsMegaphone className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Speaking</span> <span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( {!selectedModules.includes("speaking") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
</section> </section>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required /> <Input
type="text"
name="name"
onChange={(e) => setName(e)}
defaultValue={name}
label="Assignment Name"
required
/>
<Select <Select
label="Entity" label="Entity"
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(v) => setEntity(v ? v.value! : undefined)} onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{ value: entities[0]?.id, label: entities[0]?.label }} defaultValue={{
value: entities[0]?.id,
label: entities[0]?.label,
}}
/> />
</div> </div>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label> <label className="font-normal text-base text-mti-gray-dim">
Limit Start Date *
</label>
<ReactDatePicker <ReactDatePicker
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 z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())} filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -296,12 +451,14 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label> <label className="font-normal text-base text-mti-gray-dim">
End Date *
</label>
<ReactDatePicker <ReactDatePicker
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 z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)} filterTime={(date) => moment(date).isAfter(startDate)}
@@ -315,13 +472,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select <Select
value={{ value={{
value: instructorGender, value: instructorGender,
label: capitalize(instructorGender), label: capitalize(instructorGender),
}} }}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} onChange={(value) =>
value
? setInstructorGender(value.value as InstructorGender)
: null
}
disabled={!selectedModules.includes("speaking")} disabled={!selectedModules.includes("speaking")}
options={[ options={[
{ value: "male", label: "Male" }, { value: "male", label: "Male" },
@@ -341,11 +504,16 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
<div className="grid md:grid-cols-2 w-full gap-4"> <div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => ( {selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full"> <div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label> <label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select <Select
value={{ value={{
value: examIDs.find((e) => e.module === module)?.id || null, value:
label: examIDs.find((e) => e.module === module)?.id || "", examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}} }}
onChange={(value) => onChange={(value) =>
value value
@@ -353,7 +521,9 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
...prev.filter((x) => x.module !== module), ...prev.filter((x) => x.module !== module),
{ id: value.value!, module }, { id: value.value!, module },
]) ])
: setExamIDs((prev) => prev.filter((x) => x.module !== module)) : setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
} }
options={exams options={exams
.filter((x) => !x.isDiagnostic && x.module === module) .filter((x) => !x.isDiagnostic && x.module === module)
@@ -367,25 +537,40 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
)} )}
<section className="w-full flex flex-col gap-4"> <section className="w-full flex flex-col gap-4">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">
Assignees ({assignees.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>((acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
}, []);
if (groupStudentIds.every((u) => assignees.includes(u))) { if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -403,9 +588,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", assignees.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -431,25 +619,43 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span> <span className="font-semibold">
Teachers ({teachers.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>(
(acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
},
[]
);
if (groupStudentIds.every((u) => teachers.includes(u))) { if (groupStudentIds.every((u) => teachers.includes(u))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setTeachers((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setTeachers((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -467,9 +673,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", teachers.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -488,16 +697,30 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
)} )}
<div className="flex gap-4 w-full items-end"> <div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}> <Checkbox
isChecked={variant === "full"}
onChange={() =>
setVariant((prev) => (prev === "full" ? "partial" : "full"))
}
>
Full length exams Full length exams
</Checkbox> </Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}> <Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams Generate different exams
</Checkbox> </Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}> <Checkbox
isChecked={released}
onChange={() => setReleased((d) => !d)}
>
Auto release results Auto release results
</Checkbox> </Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}> <Checkbox
isChecked={autoStart}
onChange={() => setAutostart((d) => !d)}
>
Auto start exam Auto start exam
</Checkbox> </Checkbox>
</div> </div>
@@ -507,7 +730,8 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
variant="outline" variant="outline"
onClick={() => router.push("/assignments")} onClick={() => router.push("/assignments")}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}> isLoading={isLoading}
>
Cancel Cancel
</Button> </Button>
@@ -523,12 +747,13 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
} }
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}
>
Create Create
</Button> </Button>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,15 +1,12 @@
import Layout from "@/components/High/Layout";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import AssignmentCard from "@/components/AssignmentCard"; import AssignmentCard from "@/components/AssignmentCard";
import AssignmentView from "@/components/AssignmentView";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { CorporateUser, Group, User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { getUserCompanyName } from "@/resources/user";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import {
@@ -21,71 +18,147 @@ import {
} from "@/utils/assignments"; } from "@/utils/assignments";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { groupBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { BsChevronLeft, BsPlus } from "react-icons/bs"; import { BsChevronLeft, BsPlus } from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/")
if (
!checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/");
const isAdmin = checkAccess(user, ["developer", "admin"]);
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); const entities = await (isAdmin
? getEntitiesWithRoles()
: getEntitiesWithRoles(entityIDS));
const allowedEntities = findAllowedEntities(user, entities, "view_assignments") const allowedEntities = findAllowedEntities(
user,
entities,
"view_assignments"
);
const [users, assignments] = await Promise.all([
await (isAdmin
? getUsers({}, 0, {}, { _id: 0, id: 1, name: 1 })
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
name: 1,
})),
await (isAdmin
? getAssignments()
: getEntitiesAssignments(mapBy(allowedEntities, "id"))),
]);
const users = return {
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); props: serialize({ user, users, entities: allowedEntities, assignments }),
};
const assignments =
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
return { props: serialize({ user, users, entities: allowedEntities, assignments }) };
}, sessionOptions); }, sessionOptions);
const SEARCH_FIELDS = [["name"]]; const SEARCH_FIELDS = [["name"]];
interface Props { interface Props {
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
user: User; user: User;
users: User[]; users: User[];
} }
export default function AssignmentsPage({ assignments, entities, user, users }: Props) { export default function AssignmentsPage({
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment') assignments,
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment') entities,
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment') user,
users,
}: Props) {
const entitiesAllowCreate = useAllowedEntities(
user,
entities,
"create_assignment"
);
const entitiesAllowEdit = useAllowedEntities(
user,
entities,
"edit_assignment"
);
const entitiesAllowArchive = useAllowedEntities(
user,
entities,
"archive_assignment"
);
const activeAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); const activeAssignments = useMemo(
const plannedAssignments = useMemo(() => assignments.filter(futureAssignmentFilter), [assignments]); () => assignments.filter(activeAssignmentFilter),
const pastAssignments = useMemo(() => assignments.filter(pastAssignmentFilter), [assignments]); [assignments]
const startExpiredAssignments = useMemo(() => assignments.filter(startHasExpiredAssignmentFilter), [assignments]); );
const archivedAssignments = useMemo(() => assignments.filter(archivedAssignmentFilter), [assignments]); const plannedAssignments = useMemo(
() => assignments.filter(futureAssignmentFilter),
[assignments]
);
const pastAssignments = useMemo(
() => assignments.filter(pastAssignmentFilter),
[assignments]
);
const startExpiredAssignments = useMemo(
() => assignments.filter(startHasExpiredAssignmentFilter),
[assignments]
);
const archivedAssignments = useMemo(
() => assignments.filter(archivedAssignmentFilter),
[assignments]
);
const router = useRouter(); const router = useRouter();
const { rows: activeRows, renderSearch: renderActive } = useListSearch(SEARCH_FIELDS, activeAssignments); const { rows: activeRows, renderSearch: renderActive } = useListSearch(
const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(SEARCH_FIELDS, plannedAssignments); SEARCH_FIELDS,
const { rows: pastRows, renderSearch: renderPast } = useListSearch(SEARCH_FIELDS, pastAssignments); activeAssignments
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(SEARCH_FIELDS, startExpiredAssignments); );
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(SEARCH_FIELDS, archivedAssignments); const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(
SEARCH_FIELDS,
plannedAssignments
);
const { rows: pastRows, renderSearch: renderPast } = useListSearch(
SEARCH_FIELDS,
pastAssignments
);
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(
SEARCH_FIELDS,
startExpiredAssignments
);
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(
SEARCH_FIELDS,
archivedAssignments
);
const { items: activeItems, renderMinimal: paginationActive } = usePagination(activeRows, 16); const { items: activeItems, renderMinimal: paginationActive } = usePagination(
const { items: plannedItems, renderMinimal: paginationPlanned } = usePagination(plannedRows, 16); activeRows,
const { items: pastItems, renderMinimal: paginationPast } = usePagination(pastRows, 16); 16
const { items: expiredItems, renderMinimal: paginationExpired } = usePagination(expiredRows, 16); );
const { items: archivedItems, renderMinimal: paginationArchived } = usePagination(archivedRows, 16); const { items: plannedItems, renderMinimal: paginationPlanned } =
usePagination(plannedRows, 16);
const { items: pastItems, renderMinimal: paginationPast } = usePagination(
pastRows,
16
);
const { items: expiredItems, renderMinimal: paginationExpired } =
usePagination(expiredRows, 16);
const { items: archivedItems, renderMinimal: paginationArchived } =
usePagination(archivedRows, 16);
return ( return (
<> <>
@@ -98,10 +171,13 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/dashboard"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Assignments</h2> <h2 className="font-bold text-2xl">Assignments</h2>
@@ -112,35 +188,56 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<span className="text-lg font-bold">Active Assignments Status</span> <span className="text-lg font-bold">Active Assignments Status</span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span> <span>
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/ <b>Total:</b>{" "}
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)} {activeAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
)}
/
{activeAssignments.reduce(
(acc, curr) => curr.exams.length + acc,
0
)}
</span> </span>
</div> </div>
</div> </div>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({activeAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Active Assignments ({activeAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderActive()} {renderActive()}
{paginationActive()} {paginationActive()}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{activeItems.map((a) => ( {activeItems.map((a) => (
<AssignmentCard {...a} entityObj={findBy(entities, 'id', a.entity)} users={users} onClick={() => router.push(`/assignments/${a.id}`)} key={a.id} /> <AssignmentCard
{...a}
entityObj={findBy(entities, "id", a.entity)}
users={users}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
/>
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({plannedAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Planned Assignments ({plannedAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderPlanned()} {renderPlanned()}
{paginationPlanned()} {paginationPlanned()}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link <Link
href={entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""} href={
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""
}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
>
<BsPlus className="text-6xl" /> <BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span> <span className="text-lg">New Assignment</span>
</Link> </Link>
@@ -148,9 +245,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
onClick={ onClick={
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "") mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
? () => router.push(`/assignments/creator/${a.id}`) ? () => router.push(`/assignments/creator/${a.id}`)
: () => router.push(`/assignments/${a.id}`) : () => router.push(`/assignments/${a.id}`)
} }
@@ -161,7 +258,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Past Assignments ({pastAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderPast()} {renderPast()}
{paginationPast()} {paginationPast()}
@@ -171,18 +270,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)} onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id} key={a.id}
allowDownload allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")} allowArchive={mapBy(entitiesAllowArchive, "id").includes(
a.entity || ""
)}
allowExcelDownload allowExcelDownload
/> />
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Assignments start expired ({startExpiredAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Assignments start expired ({startExpiredAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderExpired()} {renderExpired()}
{paginationExpired()} {paginationExpired()}
@@ -192,18 +295,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)} onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id} key={a.id}
allowDownload allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")} allowArchive={mapBy(entitiesAllowArchive, "id").includes(
a.entity || ""
)}
allowExcelDownload allowExcelDownload
/> />
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({archivedAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Archived Assignments ({archivedAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderArchived()} {renderArchived()}
{paginationArchived()} {paginationArchived()}
@@ -215,7 +322,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
users={users} users={users}
onClick={() => router.push(`/assignments/${a.id}`)} onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id} key={a.id}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
allowDownload allowDownload
allowUnarchive allowUnarchive
allowExcelDownload allowExcelDownload
@@ -223,7 +330,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
))} ))}
</div> </div>
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
@@ -19,47 +18,93 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { capitalize } from "lodash"; import { capitalize, last } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX } from "react-icons/bs"; import {
BsBuilding,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { id } = params as { id: string }; const { id } = params as { id: string };
const group = await getGroup(id); const group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms") if (!group || !group.entity) return redirect("/classrooms");
const entity = await getEntityWithRoles(group.entity) const entity = await getEntityWithRoles(group.entity);
if (!entity) return redirect("/classrooms") if (!entity) return redirect("/classrooms");
const canView = doesEntityAllow(user, entity, "view_classrooms") const canView = doesEntityAllow(user, entity, "view_classrooms");
if (!canView) return redirect("/") if (!canView) return redirect("/");
const [linkedUsers, users] = await Promise.all([
getEntityUsers(
entity.id,
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}
),
getSpecificUsers([...group.participants, group.admin], {
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}),
]);
const linkedUsers = await getEntityUsers(entity.id)
const users = await getSpecificUsers([...group.participants, group.admin]);
const groupWithUser = convertToUsers(group, users); const groupWithUser = convertToUsers(group, users);
return { return {
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }), props: serialize({
user,
group: groupWithUser,
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
entity,
}),
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
group: GroupWithUsers; group: GroupWithUsers;
users: User[]; users: User[];
entity: EntityWithRoles entity: EntityWithRoles;
} }
export default function Home({ user, group, users, entity }: Props) { export default function Home({ user, group, users, entity }: Props) {
@@ -67,36 +112,73 @@ export default function Home({ user, group, users, entity }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom") const canAddParticipants = useEntityPermission(
const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom") user,
const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms") entity,
const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom") "add_to_classroom"
);
const canRemoveParticipants = useEntityPermission(
user,
entity,
"remove_from_classroom"
);
const canRenameClassroom = useEntityPermission(
user,
entity,
"rename_classrooms"
);
const canDeleteClassroom = useEntityPermission(
user,
entity,
"delete_classroom"
);
const nonParticipantUsers = useMemo( const nonParticipantUsers = useMemo(
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)), () =>
[users, group.participants, group.admin.id, user.id], users.filter(
(x) =>
![
...group.participants.map((g) => g.id),
group.admin.id,
user.id,
].includes(x.id)
),
[users, group.participants, group.admin.id, user.id]
); );
const { rows, renderSearch } = useListSearch<User>( const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]], [["name"], ["corporateInformation", "companyInformation", "name"]],
isAdding ? nonParticipantUsers : group.participants, isAdding ? nonParticipantUsers : group.participants
); );
const { items, renderMinimal } = usePagination<User>(rows, 20); const { items, renderMinimal } = usePagination<User>(rows, 20);
const router = useRouter(); const router = useRouter();
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
const removeParticipants = () => { const removeParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return; if (!canRemoveParticipants) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`)) if (
!confirm(
`Are you sure you want to remove ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} from this group?`
)
)
return; return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) }) .patch(`/api/groups/${group.id}`, {
participants: group.participants
.map((x) => x.id)
.filter((x) => !selectedUsers.includes(x)),
})
.then(() => { .then(() => {
toast.success("The group has been updated successfully!"); toast.success("The group has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -111,13 +193,24 @@ export default function Home({ user, group, users, entity }: Props) {
const addParticipants = () => { const addParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canAddParticipants || !isAdding) return; if (!canAddParticipants || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`)) if (
!confirm(
`Are you sure you want to add ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} to this group?`
)
)
return; return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] }) .patch(`/api/groups/${group.id}`, {
participants: [
...group.participants.map((x) => x.id),
...selectedUsers,
],
})
.then(() => { .then(() => {
toast.success("The group has been updated successfully!"); toast.success("The group has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -183,14 +276,15 @@ export default function Home({ user, group, users, entity }: Props) {
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">{group.name}</h2> <h2 className="font-bold text-2xl">{group.name}</h2>
@@ -201,14 +295,16 @@ export default function Home({ user, group, users, entity }: Props) {
<button <button
onClick={renameGroup} onClick={renameGroup}
disabled={isLoading || !canRenameClassroom} disabled={isLoading || !canRenameClassroom}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTag /> <BsTag />
<span className="text-xs">Rename Classroom</span> <span className="text-xs">Rename Classroom</span>
</button> </button>
<button <button
onClick={deleteGroup} onClick={deleteGroup}
disabled={isLoading || !canDeleteClassroom} disabled={isLoading || !canDeleteClassroom}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Delete Classroom</span> <span className="text-xs">Delete Classroom</span>
</button> </button>
@@ -220,7 +316,8 @@ export default function Home({ user, group, users, entity }: Props) {
<BsBuilding className="text-xl" /> {entity.label} <BsBuilding className="text-xl" /> {entity.label}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)} <BsFillPersonVcardFill className="text-xl" />{" "}
{getUserName(group.admin)}
</span> </span>
</div> </div>
</div> </div>
@@ -232,14 +329,20 @@ export default function Home({ user, group, users, entity }: Props) {
<button <button
onClick={() => setIsAdding(true)} onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddParticipants} disabled={isLoading || !canAddParticipants}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Participants</span> <span className="text-xs">Add Participants</span>
</button> </button>
<button <button
onClick={removeParticipants} onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveParticipants} disabled={
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 ||
isLoading ||
!canRemoveParticipants
}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Remove Participants</span> <span className="text-xs">Remove Participants</span>
</button> </button>
@@ -250,14 +353,20 @@ export default function Home({ user, group, users, entity }: Props) {
<button <button
onClick={() => setIsAdding(false)} onClick={() => setIsAdding(false)}
disabled={isLoading} disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsX /> <BsX />
<span className="text-xs">Discard Selection</span> <span className="text-xs">Discard Selection</span>
</button> </button>
<button <button
onClick={addParticipants} onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canAddParticipants} disabled={
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 ||
isLoading ||
!canAddParticipants
}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Participants</span> <span className="text-xs">Add Participants</span>
</button> </button>
@@ -269,26 +378,53 @@ export default function Home({ user, group, users, entity }: Props) {
{renderMinimal()} {renderMinimal()}
</div> </div>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => ( {["student", "teacher", "corporate"].map((type) => (
<button <button
key={type} key={type}
onClick={() => { onClick={() => {
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id') const typeUsers = mapBy(
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) { if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a))); setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else { } else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]); setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
} }
}} }}
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0} disabled={
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).length === 0
}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed", "disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length > 0 && filterBy(
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) && isAdding ? nonParticipantUsers : group.participants,
"!bg-mti-purple-light !text-white", "type",
)}> type
).length > 0 &&
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)} {capitalize(type)}
</button> </button>
))} ))}
@@ -299,20 +435,25 @@ export default function Home({ user, group, users, entity }: Props) {
{items.map((u) => ( {items.map((u) => (
<button <button
onClick={() => toggleUser(u)} onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants} disabled={
isAdding ? !canAddParticipants : !canRemoveParticipants
}
key={u.id} key={u.id}
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span> <span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div> </div>
</div> </div>
@@ -327,19 +468,25 @@ export default function Home({ user, group, users, entity }: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format(
"DD/MM/YYYY"
)
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>
))} ))}
</section> </section>
</Layout> </>
)} )}
</> </>
); );

View File

@@ -1,46 +1,78 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles} from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import {filterBy, mapBy, redirect, serialize} from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {getUserName, isAdmin} from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
import {getEntitiesUsers, getLinkedUsers} from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {Divider} from "primereact/divider"; import { Divider } from "primereact/divider";
import {useEffect, useMemo, useState} from "react"; import { useEffect, useMemo, useState } from "react";
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs"; import {
import {toast, ToastContainer} from "react-toastify"; BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
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 entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : mapBy(user.entities, "id")); const entities = await getEntitiesWithRoles(
const users = await getEntitiesUsers(mapBy(entities, 'id')) isAdmin(user) ? undefined : mapBy(user.entities, "id")
const allowedEntities = findAllowedEntities(user, entities, "create_classroom") );
const users = await getEntitiesUsers(
mapBy(entities, "id"),
{
id: { $ne: user.id },
},
0,
{
_id: 0,
id: 1,
name: 1,
email: 1,
profilePicture: 1,
type: 1,
corporateInformation: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
}
);
const allowedEntities = findAllowedEntities(
user,
entities,
"create_classroom"
);
return { return {
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}), props: serialize({
user,
entities: allowedEntities,
users: users,
}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -50,33 +82,54 @@ interface Props {
entities: EntityWithRoles[]; entities: EntityWithRoles[];
} }
export default function Home({user, users, entities}: Props) { export default function Home({ user, users, entities }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users]) const entityUsers = useMemo(
() =>
const {rows, renderSearch} = useListSearch<User>( !entity
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers ? users
: users.filter((u) => mapBy(u.entities, "id").includes(entity)),
[entity, users]
); );
const {items, renderMinimal} = usePagination<User>(rows, 16); const { rows, renderSearch } = useListSearch<User>(
[
["name"],
["type"],
["corporateInformation", "companyInformation", "name"],
],
entityUsers
);
const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter(); const router = useRouter();
useEffect(() => setSelectedUsers([]), [entity]) useEffect(() => setSelectedUsers([]), [entity]);
const createGroup = () => { const createGroup = () => {
if (!name.trim()) return; if (!name.trim()) return;
if (!entity) return; if (!entity) return;
if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return; if (
!confirm(
`Are you sure you want to create this group with ${selectedUsers.length} participants?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity}) .post<{ id: string }>(`/api/groups`, {
name,
participants: selectedUsers,
admin: user.id,
entity,
})
.then((result) => { .then((result) => {
toast.success("Your group has been created successfully!"); toast.success("Your group has been created successfully!");
router.replace(`/classrooms/${result.data.id}`); router.replace(`/classrooms/${result.data.id}`);
@@ -88,7 +141,10 @@ export default function Home({user, users, entities}: Props) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
return ( return (
<> <>
@@ -102,13 +158,14 @@ export default function Home({user, users, entities}: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between"> <div className="flex gap-3 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Create Classroom</h2> <h2 className="font-bold text-2xl">Create Classroom</h2>
@@ -117,7 +174,8 @@ export default function Home({user, users, entities}: Props) {
<button <button
onClick={createGroup} onClick={createGroup}
disabled={!name.trim() || !entity || isLoading} disabled={!name.trim() || !entity || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Create Classroom</span> <span className="text-xs">Create Classroom</span>
</button> </button>
@@ -127,46 +185,67 @@ export default function Home({user, users, entities}: Props) {
<div className="grid grid-cols-2 gap-4 place-items-end"> <div className="grid grid-cols-2 gap-4 place-items-end">
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Classroom Name:</span> <span className="font-semibold text-xl">Classroom Name:</span>
<Input name="name" onChange={setName} type="text" placeholder="Classroom A" /> <Input
name="name"
onChange={setName}
type="text"
placeholder="Classroom A"
/>
</div> </div>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity:</span> <span className="font-semibold text-xl">Entity:</span>
<Select <Select
options={entities.map((e) => ({value: e.id, label: e.label}))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(v) => setEntity(v ? v.value! : undefined)} onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}} defaultValue={{
value: entities[0]?.id,
label: entities[0]?.label,
}}
/> />
</div> </div>
</div> </div>
<Divider /> <Divider />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Participants ({selectedUsers.length} selected):</span> <span className="font-semibold text-xl">
Participants ({selectedUsers.length} selected):
</span>
</div> </div>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderSearch()} {renderSearch()}
{renderMinimal()} {renderMinimal()}
</div> </div>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => ( {["student", "teacher", "corporate"].map((type) => (
<button <button
key={type} key={type}
onClick={() => { onClick={() => {
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id') const typeUsers = mapBy(
filterBy(entityUsers, "type", type),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) { if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a))); setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else { } else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]); setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
} }
}} }}
disabled={filterBy(entityUsers, 'type', type).length === 0} disabled={filterBy(entityUsers, "type", type).length === 0}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed", "disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(entityUsers, 'type', type).length > 0 && filterBy(entityUsers, "type", type).length > 0 &&
filterBy(entityUsers, 'type', type).every((u) => selectedUsers.includes(u.id)) && filterBy(entityUsers, "type", type).every((u) =>
"!bg-mti-purple-light !text-white", selectedUsers.includes(u.id)
)}> ) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)} {capitalize(type)}
</button> </button>
))} ))}
@@ -182,15 +261,18 @@ export default function Home({user, users, entities}: Props) {
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span> <span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div> </div>
</div> </div>
@@ -205,19 +287,23 @@ export default function Home({user, users, entities}: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>
))} ))}
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -3,7 +3,6 @@ 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 Layout from "@/components/High/Layout";
import { GroupWithUsers, User } from "@/interfaces/user"; import { GroupWithUsers, User } from "@/interfaces/user";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName, isAdmin } from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
@@ -11,13 +10,13 @@ import { convertToUsers, getGroupsForEntities } from "@/utils/groups.be";
import { getSpecificUsers } from "@/utils/users.be"; import { getSpecificUsers } from "@/utils/users.be";
import Link from "next/link"; import Link from "next/link";
import { uniq } from "lodash"; import { uniq } from "lodash";
import { BsFillMortarboardFill, BsPlus } from "react-icons/bs"; import { BsPlus } from "react-icons/bs";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { FaPersonChalkboard } from "react-icons/fa6"; import { FaPersonChalkboard } from "react-icons/fa6";
@@ -28,22 +27,41 @@ import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTrans
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
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(
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms") isAdmin(user) ? undefined : entityIDS
);
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id')); const allowedEntities = findAllowedEntities(
user,
entities,
"view_classrooms"
);
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants, g.admin]))); const groups = await getGroupsForEntities(mapBy(allowedEntities, "id"));
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
const users = await getSpecificUsers(
uniq(groups.flatMap((g) => [...g.participants, g.admin])),
{ _id: 0, id: 1, name: 1, email: 1, corporateInformation: 1, type: 1 }
);
const groupsWithUsers: GroupWithUsers[] = groups.map((g) =>
convertToUsers(
g,
users.filter((x) => (isAdmin(user) ? true : !isAdmin(x)))
)
);
return { return {
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }), props: serialize({
user,
groups: groupsWithUsers,
entities: allowedEntities,
}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -60,39 +78,60 @@ const SEARCH_FIELDS = [
interface Props { interface Props {
user: User; user: User;
groups: GroupWithUsers[]; groups: GroupWithUsers[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
} }
export default function Home({ user, groups, entities }: Props) { export default function Home({ user, groups, entities }: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom'); const entitiesAllowCreate = useAllowedEntities(
user,
entities,
"create_classroom"
);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const renderCard = (group: GroupWithUsers) => ( const renderCard = (group: GroupWithUsers) => (
<Link <Link
href={`/classrooms/${group.id}`} href={`/classrooms/${group.id}`}
key={group.id} key={group.id}
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Classroom</span> <span className="bg-mti-purple text-white font-semibold px-2">
Classroom
</span>
{group.name} {group.name}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span> <span className="bg-mti-purple text-white font-semibold px-2">
Admin
</span>
{getUserName(group.admin)} {getUserName(group.admin)}
</span> </span>
{!!group.entity && ( {!!group.entity && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span> <span className="bg-mti-purple text-white font-semibold px-2">
{findBy(entities, 'id', group.entity)?.label} Entity
</span>
{findBy(entities, "id", group.entity)?.label}
</span> </span>
)} )}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Participants</span> <span className="bg-mti-purple text-white font-semibold px-2">
<span className="bg-mti-purple-light/50 px-2">{group.participants.length}</span> Participants
</span>
<span className="bg-mti-purple-light/50 px-2">
{group.participants.length}
</span>
</span> </span>
<span> <span>
{group.participants.slice(0, 3).map(getUserName).join(", ")}{' '} {group.participants.slice(0, 3).map(getUserName).join(", ")}{" "}
{group.participants.length > 3 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {group.participants.length - 3} more</span> : ""} {group.participants.length > 3 ? (
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
and {group.participants.length - 3} more
</span>
) : (
""
)}
</span> </span>
</div> </div>
<div className="w-fit"> <div className="w-fit">
@@ -104,7 +143,8 @@ export default function Home({ user, groups, entities }: Props) {
const firstCard = () => ( const firstCard = () => (
<Link <Link
href={`/classrooms/create`} href={`/classrooms/create`}
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
>
<BsPlus size={40} /> <BsPlus size={40} />
<span className="font-semibold">Create Classroom</span> <span className="font-semibold">Create Classroom</span>
</Link> </Link>
@@ -122,26 +162,35 @@ export default function Home({ user, groups, entities }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="!gap-4"> <>
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]"> <Modal
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} /> isOpen={showImport}
onClose={() => setShowImport(false)}
maxWidth="max-w-[85%]"
>
<StudentClassroomTransfer
user={user}
entities={entities}
onFinish={() => setShowImport(false)}
/>
</Modal> </Modal>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-between"> <div className="flex justify-between">
<h2 className="font-bold text-2xl">Classrooms</h2> <h2 className="font-bold text-2xl">Classrooms</h2>
{entitiesAllowCreate.length !== 0 && <button {entitiesAllowCreate.length !== 0 && (
<button
className={clsx( className={clsx(
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg", "flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
"bg-mti-purple-light border border-mti-purple-light rounded-xl text-white", "bg-mti-purple-light border border-mti-purple-light rounded-xl text-white",
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out", "hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out"
)} )}
onClick={() => setShowImport(true)} onClick={() => setShowImport(true)}
> >
<FaFileUpload className="w-5 h-5" /> <FaFileUpload className="w-5 h-5" />
Transfer Students Transfer Students
</button> </button>
} )}
</div> </div>
<Separator /> <Separator />
</div> </div>
@@ -153,7 +202,7 @@ export default function Home({ user, groups, entities }: Props) {
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard} firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,29 +1,23 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Stat, Type, User } from "@/interfaces/user";
import { Group, Stat, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be"; import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntities } from "@/utils/entities.be";
import { countGroups, getGroups } from "@/utils/groups.be"; import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { countUsers, getUser, getUsers } from "@/utils/users.be"; import { countUsersByTypes, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data,
BsEnvelopePaper, BsEnvelopePaper,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
@@ -36,39 +30,76 @@ import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
students: User[]; students: User[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
usersCount: { [key in Type]: number } usersCount: { [key in Type]: number };
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
groupsCount: number; groupsCount: number;
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/") if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const [
entities,
usersCount,
groupsCount,
students,
latestStudents,
latestTeachers,
] = await Promise.all([
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
countGroups(),
getUsers(
{ type: "student" },
10,
{
averageLevel: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
]);
const [assignmentsCount, stats] = await Promise.all([
countEntitiesAssignments(mapBy(entities, "id"), {
archived: { $ne: true },
}),
getStatsByUsers(mapBy(students, "id")),
]);
const students = await getUsers({ type: 'student' }); return {
const usersCount = { props: serialize({
student: await countUsers({ type: "student" }), user,
teacher: await countUsers({ type: "teacher" }), students,
corporate: await countUsers({ type: "corporate" }), latestStudents,
mastercorporate: await countUsers({ type: "mastercorporate" }), latestTeachers,
} usersCount,
entities,
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 }) assignmentsCount,
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 }) stats,
groupsCount,
const entities = await getEntitiesWithRoles(); }),
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } }); };
const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, 'id'));
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) };
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ export default function Dashboard({
@@ -80,7 +111,7 @@ export default function Dashboard({
entities, entities,
assignmentsCount, assignmentsCount,
stats, stats,
groupsCount groupsCount,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
@@ -96,7 +127,7 @@ export default function Dashboard({
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
@@ -133,19 +164,22 @@ export default function Dashboard({
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={usersCount.student} value={usersCount.student}
@@ -161,31 +195,19 @@ export default function Dashboard({
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,28 +1,26 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, StudentUser, Type, User } from "@/interfaces/user"; import { Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments } from "@/utils/assignments.be"; import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities } from "@/utils/groups.be"; import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import {
import { calculateAverageLevel } from "@/utils/score"; checkAccess,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { countAllowedUsers, getUsers } from "@/utils/users.be";
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
BsClipboard2Data,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsPencilSquare, BsPencilSquare,
@@ -37,10 +35,10 @@ import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
students: StudentUser[] students: StudentUser[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
userCounts: { [key in Type]: number } userCounts: { [key in Type]: number };
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
@@ -48,38 +46,117 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/") if (!checkAccess(user, ["admin", "developer", "corporate"]))
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 allowedStudentEntities = findAllowedEntities(user, entities, "view_students") const {
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers") ["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
]);
const students = const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 }); const entitiesIDS = mapBy(entities, "id") || [];
const latestStudents =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 })
const latestTeachers =
await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 })
const userCounts = await countAllowedUsers(user, entities) const [
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } }); students,
const groupsCount = await countGroupsByEntities(mapBy(entities, "id")); latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ averageLevel: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{
type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
countAllowedUsers(user, entities),
countEntitiesAssignments(entitiesIDS, {
archived: { $ne: true },
}),
countGroupsByEntities(entitiesIDS),
]);
return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) }; return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) { export default function Dashboard({
const totalCount = useMemo(() => user,
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) students,
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
stats = [],
groupsCount,
}: Props) {
const totalCount = useMemo(
() =>
userCounts.corporate +
userCounts.mastercorporate +
userCounts.student +
userCounts.teacher,
[userCounts]
);
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const totalLicenses = useMemo(
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') () =>
entities.reduce(
(acc, curr) => acc + parseInt(curr.licenses.toString()),
0
),
[entities]
);
const allowedEntityStatistics = useAllowedEntities(
user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
const router = useRouter(); const router = useRouter();
@@ -95,7 +172,7 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
{entities.length > 0 && ( {entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1"> <div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
@@ -124,14 +201,16 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`} value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple" color="purple"
/> />
{allowedEntityStatistics.length > 0 && ( {allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={allowedEntityStatistics.length} value={allowedEntityStatistics.length}
@@ -139,7 +218,8 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
/> />
)} )}
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={userCounts.student} value={userCounts.student}
@@ -149,7 +229,11 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose" color="rose"
/> />
<IconCard <IconCard
@@ -164,31 +248,19 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
</div> </div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,29 +1,22 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Stat, Type, User } from "@/interfaces/user";
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be"; import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntities } from "@/utils/entities.be";
import { countGroups, getGroups } from "@/utils/groups.be"; import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { countUsersByTypes, getUsers } from "@/utils/users.be";
import { countUsers, getUser, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data,
BsEnvelopePaper, BsEnvelopePaper,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
@@ -36,37 +29,69 @@ import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
students: User[]; students: User[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
usersCount: { [key in Type]: number } usersCount: { [key in Type]: number };
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
groupsCount: number; groupsCount: number;
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/") if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const students = await getUsers({ type: 'student' }, 10, { averageLevel: -1 }); const [
const usersCount = { students,
student: await countUsers({ type: "student" }), latestStudents,
teacher: await countUsers({ type: "teacher" }), latestTeachers,
corporate: await countUsers({ type: "corporate" }), usersCount,
mastercorporate: await countUsers({ type: "mastercorporate" }), entities,
} groupsCount,
] = await Promise.all([
getUsers(
{ type: "student" },
10,
{ averageLevel: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student" },
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "teacher" },
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
countGroups(),
]);
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 }) const assignmentsCount = await countEntitiesAssignments(
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 }) mapBy(entities, "id"),
{ archived: { $ne: true } }
);
const entities = await getEntitiesWithRoles(); return {
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } }); props: serialize({
const groupsCount = await countGroups(); user,
students,
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, groupsCount }) }; latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ export default function Dashboard({
@@ -78,7 +103,7 @@ export default function Dashboard({
entities, entities,
assignmentsCount, assignmentsCount,
stats = [], stats = [],
groupsCount groupsCount,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
@@ -94,7 +119,7 @@ export default function Dashboard({
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
@@ -131,19 +156,22 @@ export default function Dashboard({
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={entities.length} value={entities.length}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={usersCount.student} value={usersCount.student}
@@ -159,31 +187,19 @@ export default function Dashboard({
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b.id))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a.id))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,4 +1,3 @@
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";

View File

@@ -1,38 +1,31 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Stat, StudentUser, Type, User } from "@/interfaces/user";
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { countEntitiesAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities, getGroupsByEntities } from "@/utils/groups.be"; import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import {
import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; checkAccess,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { countAllowedUsers, getUsers } from "@/utils/users.be";
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsPaperclip,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
BsPeopleFill, BsPeopleFill,
@@ -44,10 +37,10 @@ import { isAdmin } from "@/utils/users";
interface Props { interface Props {
user: User; user: User;
students: StudentUser[] students: StudentUser[];
latestStudents: User[] latestStudents: User[];
latestTeachers: User[] latestTeachers: User[];
userCounts: { [key in Type]: number } userCounts: { [key in Type]: number };
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignmentsCount: number; assignmentsCount: number;
stats: Stat[]; stats: Stat[];
@@ -55,42 +48,118 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/") if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
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 {
["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
]);
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students") const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
const students = const entitiesIDS = mapBy(entities, "id") || [];
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 });
const latestStudents =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 })
const latestTeachers =
await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 })
const userCounts = await countAllowedUsers(user, entities) const [
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } }); students,
const groupsCount = await countGroupsByEntities(mapBy(entities, "id")); latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ averageLevel: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{
type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
countAllowedUsers(user, entities),
countEntitiesAssignments(entitiesIDS, { archived: { $ne: true } }),
countGroupsByEntities(entitiesIDS),
]);
return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) }; return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) { export default function Dashboard({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
stats = [],
groupsCount,
}: Props) {
const totalCount = useMemo(() => const totalCount = useMemo(
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) () =>
userCounts.corporate +
userCounts.mastercorporate +
userCounts.student +
userCounts.teacher,
[userCounts]
);
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) const totalLicenses = useMemo(
() =>
entities.reduce(
(acc, curr) => acc + parseInt(curr.licenses.toString()),
0
),
[entities]
);
const router = useRouter(); const router = useRouter();
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const allowedEntityStatistics = useAllowedEntities(
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
return ( return (
<> <>
@@ -104,7 +173,7 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
@@ -134,14 +203,16 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
value={groupsCount} value={groupsCount}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeopleFill} <IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")} onClick={() => router.push("/entities")}
label="Entities" label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`} value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple" color="purple"
/> />
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={userCounts.student} value={userCounts.student}
@@ -149,7 +220,8 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
/> />
)} )}
{allowedEntityStatistics.length > 0 && ( {allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={allowedEntityStatistics.length} value={allowedEntityStatistics.length}
@@ -161,43 +233,37 @@ export default function Dashboard({ user, students, latestStudents, latestTeache
onClick={() => router.push("/assignments")} onClick={() => router.push("/assignments")}
label="Assignments" label="Assignments"
value={assignmentsCount} value={assignmentsCount}
className={clsx(allowedEntityStatistics.length === 0 && "col-span-2")} className={clsx(
allowedEntityStatistics.length === 0 && "col-span-2"
)}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose" color="rose"
/> />
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList <UserDisplayList
users={latestStudents} users={students.sort(
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,33 +1,32 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard"; import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
import ModuleBadge from "@/components/ModuleBadge"; import ModuleBadge from "@/components/ModuleBadge";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Grading } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite"; import { InviteWithEntity } from "@/interfaces/invite";
import { Assignment } from "@/interfaces/results"; import { Assignment, AssignmentWithHasResults } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { activeAssignmentFilter } from "@/utils/assignments"; import { getAssignmentsForStudent } from "@/utils/assignments.be";
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { getGradingSystemByEntity } from "@/utils/grading.be"; import { getGradingSystemByEntity } from "@/utils/grading.be";
import { convertInvitersToEntity, getInvitesByInvitee } from "@/utils/invites.be"; import {
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils"; convertInvitersToEntity,
getInvitesByInvitee,
} from "@/utils/invites.be";
import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getGradingLabel } from "@/utils/score"; import { getGradingLabel } from "@/utils/score";
import { getSessionsByUser } from "@/utils/sessions.be"; import { getSessionsByUser } from "@/utils/sessions.be";
import { averageScore } from "@/utils/stats"; import { getDetailedStatsByUser } from "@/utils/stats.be";
import { getStatsByUser } from "@/utils/stats.be";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { capitalize, uniqBy } from "lodash"; import { capitalize, uniqBy } from "lodash";
@@ -35,14 +34,23 @@ import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar } from "react-icons/bs"; import {
BsBook,
BsClipboard,
BsFileEarmarkText,
BsHeadphones,
BsMegaphone,
BsPen,
BsPencil,
BsStar,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: AssignmentWithHasResults[];
stats: Stat[]; stats: { fullExams: number; uniqueModules: number; averageScore: number };
exams: Exam[]; exams: Exam[];
sessions: Session[]; sessions: Session[];
invites: InviteWithEntity[]; invites: InviteWithEntity[];
@@ -50,59 +58,102 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "student"])) if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/") return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const currentDate = moment().toISOString();
const entities = await getEntitiesWithRoles(entityIDS); const [assignments, stats, invites, grading] = await Promise.all([
const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } }); getAssignmentsForStudent(user.id, currentDate),
const stats = await getStatsByUser(user.id); getDetailedStatsByUser(user.id, "stats"),
const sessions = await getSessionsByUser(user.id, 10); getInvitesByInvitee(user.id),
const invites = await getInvitesByInvitee(user.id); getGradingSystemByEntity(entityIDS[0] || "", {
const grading = await getGradingSystemByEntity(entityIDS[0] || ""); _id: 0,
steps: 1,
const formattedInvites = await Promise.all(invites.map(convertInvitersToEntity)); }),
]);
const assignmentsIDs = mapBy(assignments, "id");
const [sessions, ...formattedInvites] = await Promise.all([
getSessionsByUser(user.id, 10, {
["assignment.id"]: { $in: assignmentsIDs },
}),
...invites.map(convertInvitersToEntity),
]);
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.reduce<{ module: Module; id: string; key: string }[]>(
a.exams.filter((e) => e.assignee === user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), (acc, a) => {
a.exams.forEach((e: { module: Module; id: string }) => {
acc.push({
module: e.module,
id: e.id,
key: `${e.module}_${e.id}`,
});
});
return acc;
},
[]
), ),
"key", "key"
); );
const exams = await getExamsByIds(examIDs);
return { props: serialize({ user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading }) }; const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
return {
props: serialize({
user,
assignments,
stats: stats ,
exams,
sessions,
invites: formattedInvites,
grading,
}),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, entities, assignments, stats, invites, grading, sessions, exams }: Props) { export default function Dashboard({
user,
entities,
assignments,
stats,
invites,
grading,
sessions,
exams,
}: Props) {
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const assignmentExams = exams.filter(e => { const assignmentExams = exams.filter((e) => {
const exam = findBy(assignment.exams, 'id', e.id) const exam = findBy(assignment.exams, "id", e.id);
return !!exam && exam.module === e.module return !!exam && exam.module === e.module;
}) });
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
payload: {
exams: assignmentExams.sort(sortByModule), exams: assignmentExams.sort(sortByModule),
modules: mapBy(assignmentExams.sort(sortByModule), 'module'), modules: mapBy(assignmentExams.sort(sortByModule), "module"),
assignment assignment,
} },
}) });
router.push("/exam"); router.push("/exam");
} }
}; };
const studentAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); const entitiesLabels = useMemo(
() => (entities.length > 0 ? mapBy(entities, "label")?.join(", ") : ""),
[entities]
);
return ( return (
<> <>
@@ -116,10 +167,10 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> <div className="rounded-lg bg-neutral-200 px-2 py-1 ">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entitiesLabels}</b>
</div> </div>
)} )}
@@ -127,20 +178,27 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
user={user} user={user}
items={[ items={[
{ {
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: (
value: countFullExams(stats), <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: stats?.fullExams || 0,
label: "Exams", label: "Exams",
tooltip: "Number of all conducted completed exams", tooltip: "Number of all conducted completed exams",
}, },
{ {
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: (
value: countExamModules(stats), <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: stats?.uniqueModules || 0,
label: "Modules", label: "Modules",
tooltip: "Number of all exam modules performed including Level Test", tooltip:
"Number of all exam modules performed including Level Test",
}, },
{ {
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: (
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: `${stats?.averageScore.toFixed(2) || 0}%`,
label: "Average Score", label: "Average Score",
tooltip: "Average success rate for questions responded", tooltip: "Average success rate for questions responded",
}, },
@@ -151,40 +209,50 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-black text-lg font-bold">Assignments</span> <span className="text-mti-black text-lg font-bold">Assignments</span>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."} {assignments.length === 0 &&
{studentAssignments "Assignments will appear here. It seems that for now there are no assignments for you."}
.sort((a, b) => moment(a.startDate).diff(b.startDate)) {assignments.map((assignment) => (
.map((assignment) => (
<div <div
className={clsx( className={clsx(
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4", "border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light", assignment.hasResults && "border-mti-green-light"
)} )}
key={assignment.id}> key={assignment.id}
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3> <h3 className="text-mti-black/90 text-xl font-semibold">
{assignment.name}
</h3>
<span className="flex justify-between gap-1 text-lg"> <span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span> <span>
{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>-</span> <span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span> <span>
{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}
</span>
</span> </span>
</div> </div>
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4"> <div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
{assignment.exams {assignment.exams.map((e) => (
.filter((e) => e.assignee === user.id) <ModuleBadge
.map((e) => e.module) className="scale-110 w-full"
.sort(sortByModuleName) key={e.module}
.map((module) => ( module={e.module}
<ModuleBadge className="scale-110 w-full" key={module} module={module} /> />
))} ))}
</div> </div>
{!assignment.results.map((r) => r.user).includes(user.id) && ( {!assignment.hasResults && (
<> <>
<div <div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden" className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"> data-tip="Your screen size is too small to perform an assignment"
<Button className="h-full w-full !rounded-xl" variant="outline"> >
<Button
className="h-full w-full !rounded-xl"
variant="outline"
>
Start Start
</Button> </Button>
</div> </div>
@@ -192,24 +260,33 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
data-tip="You have already started this assignment!" data-tip="You have already started this assignment!"
className={clsx( className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer", "-md:hidden h-full w-full max-w-[50%] cursor-pointer",
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip", sessions.filter(
)}> (x) => x.assignment?.id === assignment.id
).length > 0 && "tooltip"
)}
>
<Button <Button
className={clsx("w-full h-full !rounded-xl")} className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)} onClick={() => startAssignment(assignment)}
variant="outline" variant="outline"
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}> disabled={
sessions.filter(
(x) => x.assignment?.id === assignment.id
).length > 0
}
>
Start Start
</Button> </Button>
</div> </div>
</> </>
)} )}
{assignment.results.map((r) => r.user).includes(user.id) && ( {assignment.hasResults && (
<Button <Button
onClick={() => router.push("/record")} onClick={() => router.push("/record")}
color="green" color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline"> variant="outline"
>
Submitted Submitted
</Button> </Button>
)} )}
@@ -224,7 +301,11 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => ( {invites.map((invite) => (
<InviteWithUserCard key={invite.id} invite={invite} reload={() => router.replace(router.asPath)} /> <InviteWithUserCard
key={invite.id}
invite={invite}
reload={() => router.replace(router.asPath)}
/>
))} ))}
</span> </span>
</section> </section>
@@ -238,20 +319,41 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
const desiredLevel = user.desiredLevels[module] || 9; const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0; const level = user.levels[module] || 0;
return ( return (
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}> <div
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 w-full"
key={module}
>
<div className="flex items-center gap-2 md:gap-3"> <div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl"> <div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />} {module === "reading" && (
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />} <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />} )}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />} {module === "listening" && (
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />} <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
)}
{module === "writing" && (
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
)}
{module === "speaking" && (
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
)}
{module === "level" && (
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
)}
</div> </div>
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span> <span className="text-sm font-bold md:font-extrabold">
{capitalize(module)}
</span>
<span className="text-mti-gray-dim text-sm font-normal"> <span className="text-mti-gray-dim text-sm font-normal">
{module === "level" && !!grading && `English Level: ${getGradingLabel(level, grading.steps)}`} {module === "level" &&
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`} !!grading &&
`English Level: ${getGradingLabel(
level,
grading.steps
)}`}
{module !== "level" &&
`Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
</span> </span>
</div> </div>
</div> </div>
@@ -259,9 +361,17 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
<ProgressBar <ProgressBar
color={module} color={module}
label="" label=""
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)} mark={
module === "level"
? undefined
: Math.round((desiredLevel * 100) / 9)
}
markLabel={`Desired Level: ${desiredLevel}`} markLabel={`Desired Level: ${desiredLevel}`}
percentage={module === "level" ? level : Math.round((level * 100) / 9)} percentage={
module === "level"
? level
: Math.round((level * 100) / 9)
}
className="h-2 w-full" className="h-2 w-full"
/> />
</div> </div>
@@ -270,7 +380,7 @@ export default function Dashboard({ user, entities, assignments, stats, invites,
})} })}
</div> </div>
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,8 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList"; import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user"; import { Group, Stat, User } from "@/interfaces/user";
@@ -12,25 +10,28 @@ import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroupsByEntities } from "@/utils/groups.be"; import { getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import {
import { BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill, BsPersonFillGear } from "react-icons/bs"; BsEnvelopePaper,
BsPeople,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { filterAllowedUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { useMemo } from "react";
interface Props { interface Props {
user: User; user: User;
users: User[]; students: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: Assignment[];
stats: Stat[]; stats: Stat[];
@@ -38,29 +39,72 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login") if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "teacher"])) if (!checkAccess(user, ["admin", "developer", "teacher"]))
return redirect("/") return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const users = await filterAllowedUsers(user, entities)
const assignments = await getEntitiesAssignments(entityIDS); const entities = await getEntitiesWithRoles(
const stats = await getStatsByUsers(users.map((u) => u.id)); isAdmin(user) ? undefined : entityIDS
const groups = await getGroupsByEntities(entityIDS); );
return { props: serialize({ user, users, entities, assignments, stats, groups }) }; const filteredEntities = findAllowedEntities(user, entities, "view_students");
const [students, assignments, groups] = await Promise.all([
getEntitiesUsers(
mapBy(filteredEntities, "id"),
{
type: "student",
},
0,
{
_id: 0,
id: 1,
name: 1,
email: 1,
profilePicture: 1,
levels: 1,
registrationDate: 1,
}
),
getEntitiesAssignments(entityIDS),
getGroupsByEntities(entityIDS),
]);
const stats = await getStatsByUsers(students.map((u) => u.id));
return {
props: serialize({ user, students, entities, assignments, stats, groups }),
};
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { export default function Dashboard({
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); user,
students,
entities,
assignments,
stats,
groups,
}: Props) {
const router = useRouter(); const router = useRouter();
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const allowedEntityStatistics = useAllowedEntities(
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
const entitiesLabels = useMemo(
() => mapBy(entities, "label")?.join(", "),
[entities]
);
return ( return (
<> <>
@@ -74,11 +118,11 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
{entities.length > 0 && ( {entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1"> <div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entitiesLabels}</b>
</div> </div>
)} )}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center"> <section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
@@ -97,7 +141,8 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
color="purple" color="purple"
/> />
{allowedStudentPerformance.length > 0 && ( {allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")} onClick={() => router.push("/users/performance")}
label="Student Performance" label="Student Performance"
value={students.length} value={students.length}
@@ -105,7 +150,8 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
/> />
)} )}
{allowedEntityStatistics.length > 0 && ( {allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear} <IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")} onClick={() => router.push("/statistical")}
label="Entity Statistics" label="Entity Statistics"
value={allowedEntityStatistics.length} value={allowedEntityStatistics.length}
@@ -124,26 +170,29 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={students.sort((a, b) =>
dateSorter(a, b, "desc", "registrationDate")
)}
title="Latest Students" title="Latest Students"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList
users={ users={students.sort(
students
.sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length
) )}
}
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,24 +1,25 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch"; import { EntityWithRoles, Role } from "@/interfaces/entity";
import usePagination from "@/hooks/usePagination"; import { User } from "@/interfaces/user";
import { Entity, EntityWithRoles, Role } from "@/interfaces/entity";
import { GroupWithUsers, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntityWithRoles } from "@/utils/entities.be"; import { getEntityWithRoles } from "@/utils/entities.be";
import { convertToUsers, getGroup } from "@/utils/groups.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { checkAccess, doesEntityAllow, getTypesOfUser } from "@/utils/permissions"; import { doesEntityAllow } from "@/utils/permissions";
import { getUserName, isAdmin } from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
import { filterAllowedUsers, getEntitiesUsers, getEntityUsers, getLinkedUsers, getSpecificUsers, getUsers } from "@/utils/users.be"; import {
filterAllowedUsers,
getEntitiesUsers,
getEntityUsers,
getUsers,
} from "@/utils/users.be";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
@@ -28,7 +29,7 @@ import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { CURRENCIES } from "@/resources/paypal"; import { CURRENCIES } from "@/resources/paypal";
@@ -37,25 +38,27 @@ import {
BsChevronLeft, BsChevronLeft,
BsClockFill, BsClockFill,
BsEnvelopeFill, BsEnvelopeFill,
BsFillPersonVcardFill,
BsHash, BsHash,
BsPerson, BsPerson,
BsPlus, BsPlus,
BsSquare,
BsStopwatchFill, BsStopwatchFill,
BsTag, BsTag,
BsTrash, BsTrash,
BsX, BsX,
} from "react-icons/bs"; } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import entities from "../../api/entities";
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(1, "days").isAfter(momentDate))
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
}; };
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
@@ -63,26 +66,62 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
label, label,
})); }));
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const USER_DATA_SCHEMA = {
_id: 0,
id: 1,
name: 1,
type: 1,
profilePicture: 1,
email: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
entities: 1,
corporateInformation: 1,
};
export const getServerSideProps = withIronSessionSsr(
async ({ req, params }) => {
const user = req.session.user as User; const user = req.session.user as User;
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { id } = params as { id: string }; const { id } = params as { id: string };
const entity = await getEntityWithRoles(id); const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities") if (!entity) return redirect("/entities");
if (!doesEntityAllow(user, entity, "view_entities")) return redirect(`/entities`) if (!doesEntityAllow(user, entity, "view_entities"))
return redirect(`/entities`);
const linkedUsers = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(user.entities, 'id'), const [linkedUsers, entityUsers] = await Promise.all([
{ $and: [{ type: { $ne: "developer" } }, { type: { $ne: "admin" } }] })) isAdmin(user)
const entityUsers = await (isAdmin(user) ? getEntityUsers(id) : filterAllowedUsers(user, [entity])); ? getUsers({}, 0, {}, USER_DATA_SCHEMA)
: getEntitiesUsers(
mapBy(user.entities, "id"),
{
$and: [
{ type: { $ne: "developer" } },
{ type: { $ne: "admin" } },
],
},
0,
USER_DATA_SCHEMA
),
isAdmin(user)
? getEntityUsers(
id,
0,
{
id: { $ne: user.id },
},
USER_DATA_SCHEMA
)
: filterAllowedUsers(user, [entity], USER_DATA_SCHEMA),
]);
const usersWithRole = entityUsers.map((u) => { const usersWithRole = entityUsers.map((u) => {
const e = u.entities.find((e) => e.id === id); const e = u?.entities?.find((e) => e.id === id);
return { ...u, role: findBy(entity.roles, 'id', e?.role) }; return { ...u, role: findBy(entity.roles, "id", e?.role) };
}); });
return { return {
@@ -90,10 +129,14 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, params }) =>
user, user,
entity, entity,
users: usersWithRole, users: usersWithRole,
linkedUsers: linkedUsers.filter(x => x.id !== user.id && !mapBy(entityUsers, 'id').includes(x.id)), linkedUsers: linkedUsers.filter(
(x) => !mapBy(entityUsers, "id").includes(x.id)
),
}), }),
}; };
}, sessionOptions); },
sessionOptions
);
type UserWithRole = User & { role?: Role }; type UserWithRole = User & { role?: Role };
@@ -108,34 +151,52 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) const [expiryDate, setExpiryDate] = useState(entity?.expiryDate);
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price) const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price);
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency) const [paymentCurrency, setPaymentCurrency] = useState(
entity?.payment?.currency
);
const router = useRouter(); const router = useRouter();
const canRenameEntity = useEntityPermission(user, entity, "rename_entity") const canRenameEntity = useEntityPermission(user, entity, "rename_entity");
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles") const canViewRoles = useEntityPermission(user, entity, "view_entity_roles");
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity") const canDeleteEntity = useEntityPermission(user, entity, "delete_entity");
const canAddMembers = useEntityPermission(user, entity, "add_to_entity") const canAddMembers = useEntityPermission(user, entity, "add_to_entity");
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity") const canRemoveMembers = useEntityPermission(
user,
entity,
"remove_from_entity"
);
const canAssignRole = useEntityPermission(user, entity, "assign_to_role") const canAssignRole = useEntityPermission(user, entity, "assign_to_role");
const canPay = useEntityPermission(user, entity, 'pay_entity') const canPay = useEntityPermission(user, entity, "pay_entity");
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
const removeParticipants = () => { const removeParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canRemoveMembers) return; if (!canRemoveMembers) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} from this entity?`)) if (
!confirm(
`Are you sure you want to remove ${selectedUsers.length} member${
selectedUsers.length === 1 ? "" : "s"
} from this entity?`
)
)
return; return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/entities/${entity.id}/users`, { add: false, members: selectedUsers }) .patch(`/api/entities/${entity.id}/users`, {
add: false,
members: selectedUsers,
})
.then(() => { .then(() => {
toast.success("The entity has been updated successfully!"); toast.success("The entity has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -151,13 +212,24 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const addParticipants = () => { const addParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canAddMembers || !isAdding) return; if (!canAddMembers || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} to this entity?`)) return; if (
!confirm(
`Are you sure you want to add ${selectedUsers.length} member${
selectedUsers.length === 1 ? "" : "s"
} to this entity?`
)
)
return;
setIsLoading(true); setIsLoading(true);
const defaultRole = findBy(entity.roles, 'isDefault', true)! const defaultRole = findBy(entity.roles, "isDefault", true)!;
axios axios
.patch(`/api/entities/${entity.id}/users`, { add: true, members: selectedUsers, role: defaultRole.id }) .patch(`/api/entities/${entity.id}/users`, {
add: true,
members: selectedUsers,
role: defaultRole.id,
})
.then(() => { .then(() => {
toast.success("The entity has been updated successfully!"); toast.success("The entity has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -212,7 +284,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } }) .patch(`/api/entities/${entity.id}`, {
payment: { price: paymentPrice, currency: paymentCurrency },
})
.then(() => { .then(() => {
toast.success("The entity has been updated successfully!"); toast.success("The entity has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -227,9 +301,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const editLicenses = () => { const editLicenses = () => {
if (!isAdmin(user)) return; if (!isAdmin(user)) return;
const licenses = prompt("Update the number of licenses:", (entity.licenses || 0).toString()); const licenses = prompt(
"Update the number of licenses:",
(entity.licenses || 0).toString()
);
if (!licenses) return; if (!licenses) return;
if (!parseInt(licenses) || parseInt(licenses) <= 0) return toast.error("Write a valid number of licenses!") if (!parseInt(licenses) || parseInt(licenses) <= 0)
return toast.error("Write a valid number of licenses!");
setIsLoading(true); setIsLoading(true);
axios axios
@@ -265,8 +343,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
}; };
const assignUsersToRole = (role: string) => { const assignUsersToRole = (role: string) => {
if (!canAssignRole) return if (!canAssignRole) return;
if (selectedUsers.length === 0) return if (selectedUsers.length === 0) return;
setIsLoading(true); setIsLoading(true);
axios axios
@@ -280,7 +358,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
toast.error("Something went wrong!"); toast.error("Something went wrong!");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
} };
const renderCard = (u: UserWithRole) => { const renderCard = (u: UserWithRole) => {
return ( return (
@@ -291,8 +369,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
@@ -317,13 +396,17 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>
@@ -344,17 +427,21 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/entities" href="/entities"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2> <h2 className="font-bold text-2xl">
{entity.label}{" "}
{isAdmin(user) && `- ${entity.licenses || 0} licenses`}
</h2>
</div> </div>
{!isAdmin(user) && canPay && ( {!isAdmin(user) && canPay && (
@@ -363,11 +450,15 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-2 w-full max-w-[200px] flex justify-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",
!entity.expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(entity.expiryDate), !entity.expiryDate
"bg-white border-mti-gray-platinum", ? "!bg-mti-green-ultralight !border-mti-green-light"
)}> : expirationDateColor(entity.expiryDate),
"bg-white border-mti-gray-platinum"
)}
>
{!entity.expiryDate && "Unlimited"} {!entity.expiryDate && "Unlimited"}
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")} {entity.expiryDate &&
moment(entity.expiryDate).format("DD/MM/YYYY")}
</Link> </Link>
)} )}
</div> </div>
@@ -375,7 +466,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={renameGroup} onClick={renameGroup}
disabled={isLoading || !canRenameEntity} disabled={isLoading || !canRenameEntity}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTag /> <BsTag />
<span className="text-xs">Rename Entity</span> <span className="text-xs">Rename Entity</span>
</button> </button>
@@ -383,7 +475,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={editLicenses} onClick={editLicenses}
disabled={isLoading || !isAdmin(user)} disabled={isLoading || !isAdmin(user)}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsHash /> <BsHash />
<span className="text-xs">Edit Licenses</span> <span className="text-xs">Edit Licenses</span>
</button> </button>
@@ -391,14 +484,16 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={() => router.push(`/entities/${entity.id}/roles`)} onClick={() => router.push(`/entities/${entity.id}/roles`)}
disabled={isLoading || !canViewRoles} disabled={isLoading || !canViewRoles}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPerson /> <BsPerson />
<span className="text-xs">Edit Roles</span> <span className="text-xs">Edit Roles</span>
</button> </button>
<button <button
onClick={deleteGroup} onClick={deleteGroup}
disabled={isLoading || !canDeleteEntity} disabled={isLoading || !canDeleteEntity}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Delete Entity</span> <span className="text-xs">Delete Entity</span>
</button> </button>
@@ -416,8 +511,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-2 w-full max-w-[200px] 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",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate), !expiryDate
"transition duration-300 ease-in-out", ? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"transition duration-300 ease-in-out"
)} )}
filterDate={(date) => moment(date).isAfter(new Date())} filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
@@ -431,8 +528,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-2 w-full max-w-[200px] flex justify-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",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate), !expiryDate
"bg-white border-mti-gray-platinum", ? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum"
)} )}
> >
Unlimited Unlimited
@@ -441,17 +540,21 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Checkbox <Checkbox
isChecked={!!expiryDate} isChecked={!!expiryDate}
onChange={(checked: boolean) => setExpiryDate(checked ? entity.expiryDate || new Date() : null)} onChange={(checked: boolean) =>
setExpiryDate(
checked ? entity.expiryDate || new Date() : null
)
}
> >
Enable expiry date Enable expiry date
</Checkbox> </Checkbox>
</div> </div>
<button <button
onClick={updateExpiryDate} onClick={updateExpiryDate}
disabled={expiryDate === entity.expiryDate} disabled={expiryDate === entity.expiryDate}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Apply Change</span> <span className="text-xs">Apply Change</span>
</button> </button>
@@ -463,25 +566,34 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<div className="w-full max-w-xl flex items-center gap-4"> <div className="w-full max-w-xl flex items-center gap-4">
<Input <Input
name="paymentValue" name="paymentValue"
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)} onChange={(e) =>
setPaymentPrice(e ? parseInt(e) : undefined)
}
type="number" type="number"
defaultValue={entity.payment?.price || 0} defaultValue={entity.payment?.price || 0}
thin thin
/> />
<Select <Select
className={clsx( className={clsx(
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none", "px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
)} )}
options={CURRENCIES_OPTIONS} options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)} value={CURRENCIES_OPTIONS.find(
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)} (c) => c.value === paymentCurrency
)}
onChange={(value) =>
setPaymentCurrency(value?.value ?? undefined)
}
/> />
</div> </div>
<button <button
onClick={updatePayment} onClick={updatePayment}
disabled={!paymentPrice || paymentPrice <= 0 || !paymentCurrency} disabled={
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> !paymentPrice || paymentPrice <= 0 || !paymentCurrency
}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Apply Change</span> <span className="text-xs">Apply Change</span>
</button> </button>
@@ -491,28 +603,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Divider /> <Divider />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({users.length})</span> <span className="font-semibold text-xl">
Members ({users.length})
</span>
{!isAdding && ( {!isAdding && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setIsAdding(true)} onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddMembers} disabled={isLoading || !canAddMembers}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Members</span> <span className="text-xs">Add Members</span>
</button> </button>
<Menu> <Menu>
<MenuButton <MenuButton
disabled={isLoading || !canAssignRole || selectedUsers.length === 0} disabled={
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> isLoading || !canAssignRole || selectedUsers.length === 0
}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPerson /> <BsPerson />
<span className="text-xs">Assign Role</span> <span className="text-xs">Assign Role</span>
</MenuButton> </MenuButton>
<MenuItems anchor="bottom" className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col"> <MenuItems
anchor="bottom"
className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col"
>
{entity.roles.map((role) => ( {entity.roles.map((role) => (
<MenuItem key={role.id}> <MenuItem key={role.id}>
<button onClick={() => assignUsersToRole(role.id)} className="p-4 hover:bg-neutral-100 w-32"> <button
onClick={() => assignUsersToRole(role.id)}
className="p-4 hover:bg-neutral-100 w-32"
>
{role.label} {role.label}
</button> </button>
</MenuItem> </MenuItem>
@@ -522,8 +646,11 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={removeParticipants} onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveMembers} disabled={
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 || isLoading || !canRemoveMembers
}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Remove Members</span> <span className="text-xs">Remove Members</span>
</button> </button>
@@ -534,16 +661,22 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={() => setIsAdding(false)} onClick={() => setIsAdding(false)}
disabled={isLoading} disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsX /> <BsX />
<span className="text-xs">Discard Selection</span> <span className="text-xs">Discard Selection</span>
</button> </button>
<button <button
onClick={addParticipants} onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canAddMembers} disabled={
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 || isLoading || !canAddMembers
}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Members ({selectedUsers.length})</span> <span className="text-xs">
Add Members ({selectedUsers.length})
</span>
</button> </button>
</div> </div>
)} )}
@@ -552,10 +685,16 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<CardList<User | UserWithRole> <CardList<User | UserWithRole>
list={isAdding ? linkedUsers : users} list={isAdding ? linkedUsers : users}
renderCard={renderCard} renderCard={renderCard}
searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]} searchFields={[
["name"],
["email"],
["corporateInformation", "companyInformation", "name"],
["role", "label"],
["type"],
]}
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,4 +1,3 @@
import Layout from "@/components/High/Layout";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
@@ -152,7 +151,7 @@ interface Props {
disableEdit?: boolean disableEdit?: boolean
} }
export default function Role({ user, entity, role, userCount, disableEdit }: Props) { export default function EntityRole({ user, entity, role, userCount, disableEdit }: Props) {
const [permissions, setPermissions] = useState(role.permissions) const [permissions, setPermissions] = useState(role.permissions)
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -240,7 +239,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
@@ -388,7 +387,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
</div> </div>
</section> </section>
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,42 +1,25 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import {useListSearch} from "@/hooks/useListSearch"; import { EntityWithRoles, Role} from "@/interfaces/entity";
import usePagination from "@/hooks/usePagination"; import { User} from "@/interfaces/user";
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
import {GroupWithUsers, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import { redirect, serialize } from "@/utils"; import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import {getEntityWithRoles} from "@/utils/entities.be"; import {getEntityWithRoles} from "@/utils/entities.be";
import {convertToUsers, getGroup} from "@/utils/groups.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {checkAccess, doesEntityAllow, getTypesOfUser} from "@/utils/permissions"; import { doesEntityAllow} from "@/utils/permissions";
import {getUserName} from "@/utils/users"; import {getEntityUsers} from "@/utils/users.be";
import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import { import {
BsChevronLeft, BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus, BsPlus,
BsSquare,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs"; } from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
@@ -133,7 +116,7 @@ export default function Home({user, entity, roles, users}: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
@@ -152,7 +135,7 @@ export default function Home({user, entity, roles, users}: Props) {
<CardList list={roles} searchFields={[["label"]]} renderCard={renderCard} firstCard={canCreateRole ? firstCard : undefined} /> <CardList list={roles} searchFields={[["label"]]} renderCard={renderCard} firstCard={canCreateRole ? firstCard : undefined} />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,19 +1,16 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { mapBy, redirect, serialize } from "@/utils"; import { redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName } from "@/utils/users"; import { getUserName } from "@/utils/users";
import { getLinkedUsers, getUsers } from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
@@ -23,22 +20,41 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useState } from "react"; import { useState } from "react";
import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs"; import {
BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
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("/");
if (!["admin", "developer"].includes(user.type)) return redirect("/entities") if (!["admin", "developer"].includes(user.type)) return redirect("/entities");
const users = await getUsers() const users = await getUsers(
{ id: { $ne: user.id } },
0,
{},
{
_id: 0,
id: 1,
name: 1,
type: 1,
profilePicture: 1,
email: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
}
);
return { return {
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }), props: serialize({ user, users }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -53,19 +69,31 @@ export default function Home({ user, users }: Props) {
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [licenses, setLicenses] = useState(0); const [licenses, setLicenses] = useState(0);
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users); const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
users
);
const { items, renderMinimal } = usePagination<User>(rows, 16); const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter(); const router = useRouter();
const createGroup = () => { const createGroup = () => {
if (!label.trim()) return; if (!label.trim()) return;
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return; if (
!confirm(
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers }) .post<Entity>(`/api/entities`, {
label,
licenses,
members: selectedUsers,
})
.then((result) => { .then((result) => {
toast.success("Your entity has been created successfully!"); toast.success("Your entity has been created successfully!");
router.replace(`/entities/${result.data.id}`); router.replace(`/entities/${result.data.id}`);
@@ -77,7 +105,10 @@ export default function Home({ user, users }: Props) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
return ( return (
<> <>
@@ -91,13 +122,14 @@ export default function Home({ user, users }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <>
<section className="flex flex-col gap-0"> <section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between"> <div className="flex gap-3 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Create Entity</h2> <h2 className="font-bold text-2xl">Create Entity</h2>
@@ -106,7 +138,8 @@ export default function Home({ user, users }: Props) {
<button <button
onClick={createGroup} onClick={createGroup}
disabled={!label.trim() || licenses <= 0 || isLoading} disabled={!label.trim() || licenses <= 0 || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Create Entity</span> <span className="text-xs">Create Entity</span>
</button> </button>
@@ -116,17 +149,30 @@ export default function Home({ user, users }: Props) {
<div className="w-full grid grid-cols-2 gap-4"> <div className="w-full grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity Label:</span> <span className="font-semibold text-xl">Entity Label:</span>
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" /> <Input
name="name"
onChange={setLabel}
type="text"
placeholder="Entity A"
/>
</div> </div>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Licenses:</span> <span className="font-semibold text-xl">Licenses:</span>
<Input name="licenses" min={0} onChange={(v) => setLicenses(parseInt(v))} type="number" placeholder="12" /> <Input
name="licenses"
min={0}
onChange={(v) => setLicenses(parseInt(v))}
type="number"
placeholder="12"
/>
</div> </div>
</div> </div>
<Divider /> <Divider />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span> <span className="font-semibold text-xl">
Members ({selectedUsers.length} selected):
</span>
</div> </div>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderSearch()} {renderSearch()}
@@ -143,15 +189,18 @@ export default function Home({ user, users }: Props) {
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span> <span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div> </div>
</div> </div>
@@ -166,19 +215,23 @@ export default function Home({ user, users }: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>
))} ))}
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -3,15 +3,12 @@ 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 Layout from "@/components/High/Layout"; import { User } from "@/interfaces/user";
import { GroupWithUsers, User } from "@/interfaces/user";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName, isAdmin } from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
import { convertToUsers, getGroupsForUser } from "@/utils/groups.be"; import { countEntityUsers, getEntityUsers } from "@/utils/users.be";
import { countEntityUsers, getEntityUsers, getSpecificUsers, getUsers } from "@/utils/users.be"; import { findAllowedEntities } from "@/utils/permissions";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import Link from "next/link"; import Link from "next/link";
import { uniq } from "lodash";
import { BsBank, BsPlus } from "react-icons/bs"; import { BsBank, BsPlus } from "react-icons/bs";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -20,25 +17,53 @@ import Separator from "@/components/Low/Separator";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
type EntitiesWithCount = { entity: EntityWithRoles; users: User[]; count: number }; type EntitiesWithCount = {
entity: EntityWithRoles;
users: User[];
count: number;
};
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
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(["admin", "developer"].includes(user.type) ? undefined : entityIDs); const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, 'view_entities') ["admin", "developer"].includes(user.type) ? undefined : entityIDs
const entitiesWithCount = await Promise.all(
allowedEntities.map(async (e) => ({
entity: e,
count: await countEntityUsers(e.id, { type: { $in: ["student", "teacher", "corporate", "mastercorporate"] } }),
users: await getEntityUsers(e.id, 5, { type: { $in: ["student", "teacher", "corporate", "mastercorporate"] } })
})),
); );
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
const [counts, users] = await Promise.all([
await Promise.all(
allowedEntities.map(async (e) =>
countEntityUsers(e.id, {
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
})
)
),
await Promise.all(
allowedEntities.map(async (e) =>
getEntityUsers(
e.id,
5,
{
type: {
$in: ["student", "teacher", "corporate", "mastercorporate"],
},
},
{ name: 1 }
)
)
),
]);
const entitiesWithCount = allowedEntities.map<{
entity: EntityWithRoles;
users: User[];
count: number;
}>((e, i) => ({ entity: e, users: users[i], count: counts[i] }));
return { return {
props: serialize({ user, entities: entitiesWithCount }), props: serialize({ user, entities: entitiesWithCount }),
@@ -56,19 +81,33 @@ export default function Home({ user, entities }: Props) {
<Link <Link
href={`/entities/${entity.id}`} href={`/entities/${entity.id}`}
key={entity.id} key={entity.id}
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span> <span className="bg-mti-purple text-white font-semibold px-2">
Entity
</span>
{entity.label} {entity.label}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Members</span> <span className="bg-mti-purple text-white font-semibold px-2">
<span className="bg-mti-purple-light/50 px-2">{count}{isAdmin(user) && ` / ${entity.licenses || 0}`}</span> Members
</span>
<span className="bg-mti-purple-light/50 px-2">
{count}
{isAdmin(user) && ` / ${entity.licenses || 0}`}
</span>
</span> </span>
<span> <span>
{users.map(getUserName).join(", ")}{' '} {users.map(getUserName).join(", ")}{" "}
{count > 5 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {count - 5} more</span> : ""} {count > 5 ? (
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
and {count - 5} more
</span>
) : (
""
)}
</span> </span>
</div> </div>
<div className="w-fit"> <div className="w-fit">
@@ -80,7 +119,8 @@ export default function Home({ user, entities }: Props) {
const firstCard = () => ( const firstCard = () => (
<Link <Link
href={`/entities/create`} href={`/entities/create`}
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
>
<BsPlus size={40} /> <BsPlus size={40} />
<span className="font-semibold">Create Entity</span> <span className="font-semibold">Create Entity</span>
</Link> </Link>
@@ -98,7 +138,7 @@ export default function Home({ user, entities }: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="!gap-4"> <>
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Entities</h2> <h2 className="font-bold text-2xl">Entities</h2>
@@ -109,10 +149,12 @@ export default function Home({ user, entities }: Props) {
list={entities} list={entities}
searchFields={SEARCH_FIELDS} searchFields={SEARCH_FIELDS}
renderCard={renderCard} renderCard={renderCard}
firstCard={["admin", "developer"].includes(user.type) ? firstCard : undefined} firstCard={
["admin", "developer"].includes(user.type) ? firstCard : undefined
}
/> />
</section> </section>
</Layout> </>
</> </>
); );
} }

View File

@@ -20,79 +20,104 @@ import { useRouter } from "next/router";
import { getSessionByAssignment } from "@/utils/sessions.be"; import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { activeAssignmentFilter } from "@/utils/assignments"; import { activeAssignmentFilter } from "@/utils/assignments";
import { checkAccess } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, query }) => {
const loginDestination = Buffer.from(req.url || "/").toString("base64") const user = await requestUser(req, res);
if (!user) return redirect(`/login?destination=${loginDestination}`) const loginDestination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${loginDestination}`);
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string } const { assignment: assignmentID, destination } = query as {
const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined assignment?: string;
destination?: string;
};
const destinationURL = !!destination
? Buffer.from(destination, "base64").toString()
: undefined;
if (!!assignmentID) { if (!!assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect(destinationURL || "/exam") if (!assignment) return redirect(destinationURL || "/exam");
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type)) if (
return redirect(destinationURL || "/exam") !assignment.assignees.includes(user.id) &&
!["admin", "developer"].includes(user.type)
)
return redirect(destinationURL || "/exam");
if (filterBy(assignment.results, 'user', user.id).length > 0) if (filterBy(assignment.results, "user", user.id).length > 0)
return redirect(destinationURL || "/exam") return redirect(destinationURL || "/exam");
const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) const [exams, session] = await Promise.all([
const session = await getSessionByAssignment(assignmentID) getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
return { return {
props: serialize({ user, assignment, exams, destinationURL, session: session ?? undefined }) props: serialize({
} user,
assignment,
exams,
destinationURL,
session: session ?? undefined,
}),
};
} }
return { return {
props: serialize({ user, destinationURL }), props: serialize({ user, destinationURL }),
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment assignment?: Assignment;
exams?: Exam[] exams?: Exam[];
session?: Session session?: Session;
destinationURL?: string destinationURL?: string;
} }
const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => { const Page: React.FC<Props> = ({
const router = useRouter() user,
assignment,
exams = [],
destinationURL = "/exam",
session,
}) => {
const router = useRouter();
const { assignment: storeAssignment, dispatch } = useExamStore(); const { assignment: storeAssignment, dispatch } = useExamStore();
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
if (!activeAssignmentFilter(assignment)) return if (!activeAssignmentFilter(assignment)) return;
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
payload: {
exams: exams.sort(sortByModule), exams: exams.sort(sortByModule),
modules: exams modules: exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
assignment assignment,
} },
}); });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } }) dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
return ( return (
<> <>
@@ -105,10 +130,15 @@ const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL =
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!storeAssignment} /> <ExamPage
page="exams"
destination={destinationURL}
user={user}
hideSidebar={!!assignment || !!storeAssignment}
/>
</> </>
); );
} };
//Page.whyDidYouRender = true; //Page.whyDidYouRender = true;
export default Page; export default Page;

View File

@@ -21,79 +21,87 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import moment from "moment"; import moment from "moment";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, query }) => {
const destination = Buffer.from(req.url || "/").toString("base64") const user = await requestUser(req, res);
if (!user) return redirect(`/login?destination=${destination}`) const destination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${destination}`);
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { assignment: assignmentID } = query as { assignment?: string } const { assignment: assignmentID } = query as { assignment?: string };
if (assignmentID) { if (assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect("/exam")
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
if (!assignment) return redirect("/exam");
if ( if (
filterBy(assignment.results, 'user', user.id) || !["admin", "developer"].includes(user.type) &&
!assignment.assignees.includes(user.id)
)
return redirect("/exercises");
if (
filterBy(assignment.results, "user", user.id) ||
moment(assignment.startDate).isBefore(moment()) || moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment()) moment(assignment.endDate).isAfter(moment())
) )
return redirect("/exam") return redirect("/exam");
const [exams, session] = await Promise.all([
getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
return { return {
props: serialize({ user, assignment, exams, session }) props: serialize({ user, assignment, exams, session }),
} };
} }
return { return {
props: serialize({ user }), props: serialize({ user }),
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment assignment?: Assignment;
exams?: Exam[] exams?: Exam[];
session?: Session session?: Session;
} }
export default function Page({ user, assignment, exams = [], session }: Props) { export default function Page({ user, assignment, exams = [], session }: Props) {
const router = useRouter() const router = useRouter();
const { assignment: storeAssignment, dispatch } = useExamStore() const { assignment: storeAssignment, dispatch } = useExamStore();
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
payload: {
exams: exams.sort(sortByModule), exams: exams.sort(sortByModule),
modules: exams modules: exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
assignment assignment,
} },
}) });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } }); dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
return ( return (
<> <>

View File

@@ -2,24 +2,22 @@
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 { toast, ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
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 { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor"; import ExamEditor from "@/components/ExamEditor";
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
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, getExams } from "@/utils/exams.be"; import { getExam, } from "@/utils/exams.be";
import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam"; import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -157,7 +155,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <>
<h1 className="text-2xl font-semibold">Exam Editor</h1> <h1 className="text-2xl font-semibold">Exam Editor</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input <Input
@@ -175,7 +173,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
<RadioGroup <RadioGroup
value={currentModule} value={currentModule}
onChange={(currentModule) => updateRoot({ currentModule })} onChange={(currentModule) => updateRoot({ currentModule })}
className="flex flex-row -2xl: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]).map((x) => ( {[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => (
<Radio value={x} key={x}> <Radio value={x} key={x}>
{({ checked }) => ( {({ checked }) => (
@@ -212,7 +210,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
</RadioGroup> </RadioGroup>
</div> </div>
<ExamEditor levelParts={examLevelParts} /> <ExamEditor levelParts={examLevelParts} />
</Layout> </>
)} )}
</> </>
); );

View File

@@ -1,11 +1,10 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import AssignmentCard from "@/components/High/AssignmentCard"; import AssignmentCard from "@/components/High/AssignmentCard";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Grading } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite"; import { InviteWithEntity } from "@/interfaces/invite";
@@ -13,11 +12,13 @@ import { Assignment } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments"; import {
activeAssignmentFilter,
futureAssignmentFilter,
} from "@/utils/assignments";
import { getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
@@ -26,7 +27,6 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -45,66 +45,115 @@ interface Props {
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
const destination = Buffer.from(req.url || "/").toString("base64") const destination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${destination}`) if (!user) return redirect(`/login?destination=${destination}`);
if (!checkAccess(user, ["admin", "developer", "student"])) if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/") return redirect("/");
const assignments = (await getAssignmentsByAssignee(
user.id,
{
archived: { $ne: true },
},
{
_id: 0,
id: 1,
name: 1,
startDate: 1,
endDate: 1,
exams: 1,
results: 1,
},
{
sort: { startDate: 1 },
}
)) as Assignment[];
const entityIDS = mapBy(user.entities, "id") || []; const sessions = await getSessionsByUser(
user.id,
const entities = await getEntitiesWithRoles(entityIDS); 0,
const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } }); {
const sessions = await getSessionsByUser(user.id, 0, { "assignment.id": { $in: mapBy(assignments, 'id') } }); "assignment.id": { $in: mapBy(assignments, "id") },
},
{
_id: 0,
id: 1,
assignment: 1,
}
);
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.reduce<{ module: Module; id: string; key: string }[]>(
filterBy(a.exams, 'assignee', user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), (acc, a) => {
a.exams.forEach((e) => {
if (e.assignee === user.id)
acc.push({
module: e.module,
id: e.id,
key: `${e.module}_${e.id}`,
});
});
return acc;
},
[]
), ),
"key", "key"
); );
const exams = await getExamsByIds(examIDs); const exams = await getExamsByIds(examIDs);
return { props: serialize({ user, entities, assignments, exams, sessions }) }; return { props: serialize({ user, assignments, exams, sessions }) };
}, sessionOptions); }, sessionOptions);
const destination = Buffer.from("/official-exam").toString("base64") const destination = Buffer.from("/official-exam").toString("base64");
export default function OfficialExam({ user, entities, assignments, sessions, exams }: Props) { export default function OfficialExam({
const [isLoading, setIsLoading] = useState(false) user,
entities,
assignments,
sessions,
exams,
}: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
const reload = () => { const reload = () => {
setIsLoading(true) setIsLoading(true);
router.replace(router.asPath) router.replace(router.asPath);
setTimeout(() => setIsLoading(false), 500) setTimeout(() => setIsLoading(false), 500);
} };
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const assignmentExams = exams.filter(e => { const assignmentExams = exams.filter((e) => {
const exam = findBy(assignment.exams, 'id', e.id) const exam = findBy(assignment.exams, "id", e.id);
return !!exam && exam.module === e.module return !!exam && exam.module === e.module;
}) });
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
const sortedAssignmentExams = assignmentExams.sort(sortByModule);
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
exams: assignmentExams.sort(sortByModule), payload: {
modules: mapBy(assignmentExams.sort(sortByModule), 'module'), exams: sortedAssignmentExams,
assignment modules: mapBy(sortedAssignmentExams, "module"),
} assignment,
}) },
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`); });
router.push(
`/exam?assignment=${assignment.id}&destination=${destination}`
);
} }
}; };
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
dispatch({type: "SET_SESSION", payload: {session}}); dispatch({ type: "SET_SESSION", payload: { session } });
router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`); router.push(
`/exam?assignment=${session.assignment?.id}&destination=${destination}`
);
}; };
const logout = async () => { const logout = async () => {
@@ -113,12 +162,25 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
}); });
}; };
const studentAssignments = useMemo(() => [ const studentAssignments = useMemo(
...assignments.filter(activeAssignmentFilter), ...assignments.filter(futureAssignmentFilter)], () => [
...assignments.filter(activeAssignmentFilter),
...assignments.filter(futureAssignmentFilter),
],
[assignments] [assignments]
); );
const assignmentSessions = useMemo(() => sessions.filter(s => mapBy(studentAssignments, 'id').includes(s.assignment?.id || "")), [sessions, studentAssignments]) const assignmentSessions = useMemo(() => {
const studentAssignmentsIDs = mapBy(studentAssignments, "id");
return sessions.filter((s) =>
studentAssignmentsIDs.includes(s.assignment?.id || "")
);
}, [sessions, studentAssignments]);
const entityLabels = useMemo(
() => mapBy(entities, "label")?.join(","),
[entities]
);
return ( return (
<> <>
@@ -132,10 +194,10 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} hideSidebar> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> <div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entityLabels}</b>
</div> </div>
)} )}
@@ -147,29 +209,42 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<div <div
onClick={reload} onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<span className="text-mti-black text-lg font-bold">Assignments</span> >
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} /> <span className="text-mti-black text-lg font-bold">
Assignments
</span>
<BsArrowRepeat
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."} {studentAssignments.length === 0 &&
{studentAssignments "Assignments will appear here. It seems that for now there are no assignments for you."}
.sort((a, b) => moment(a.startDate).diff(b.startDate)) {studentAssignments.map((a) => (
.map((a) =>
<AssignmentCard <AssignmentCard
key={a.id} key={a.id}
assignment={a} assignment={a}
user={user} user={user}
session={assignmentSessions.find(s => s.assignment?.id === a.id)} session={assignmentSessions.find(
(s) => s.assignment?.id === a.id
)}
startAssignment={startAssignment} startAssignment={startAssignment}
resumeAssignment={loadSession} resumeAssignment={loadSession}
/> />
)} ))}
</span> </span>
</section> </section>
<Button onClick={logout} variant="outline" color="red" className="max-w-[200px] w-full absolute bottom-8 left-8">Sign out</Button> <Button
</Layout> onClick={logout}
variant="outline"
color="red"
className="max-w-[200px] w-full absolute bottom-8 left-8"
>
Sign out
</Button>
</>
</> </>
); );
} }

View File

@@ -2,14 +2,12 @@
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 useUser from "@/hooks/useUser";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import usePayments from "@/hooks/usePayments"; import usePayments from "@/hooks/usePayments";
import usePaypalPayments from "@/hooks/usePaypalPayments"; import usePaypalPayments from "@/hooks/usePaypalPayments";
import { Payment, PaypalPayment } from "@/interfaces/paypal"; import { Payment, PaypalPayment } from "@/interfaces/paypal";
import { CellContext, createColumnHelper, flexRender, getCoreRowModel, HeaderGroup, Table, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, flexRender, getCoreRowModel, HeaderGroup, Table, useReactTable } from "@tanstack/react-table";
import { CURRENCIES } from "@/resources/paypal"; import { CURRENCIES } from "@/resources/paypal";
import { BsTrash } from "react-icons/bs"; import { BsTrash } from "react-icons/bs";
import axios from "axios"; import axios from "axios";
@@ -33,7 +31,7 @@ import { useListSearch } from "@/hooks/useListSearch";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity, EntityWithRoles } from "@/interfaces/entity";
@@ -943,7 +941,7 @@ export default function PaymentRecord({ user, entities }: Props) {
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <>
{getUserModal()} {getUserModal()}
<Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}> <Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}>
<PaymentCreator <PaymentCreator
@@ -1248,7 +1246,7 @@ export default function PaymentRecord({ user, entities }: Props) {
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</Layout> </>
)} )}
</> </>
); );

View File

@@ -21,11 +21,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user) return redirect("/login") if (!user) return redirect("/login")
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const domain = user.email.split("@").pop() const domain = user.email.split("@").pop()
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray() const [entities, discounts, packages] = await Promise.all([
const packages = await db.collection<Package>("packages").find().toArray() getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs),
db.collection<Discount>("discounts").find({ domain }).toArray(),
db.collection<Package>("packages").find().toArray(),
])
return { return {
props: serialize({ user, entities, discounts, packages }), props: serialize({ user, entities, discounts, packages }),

View File

@@ -1,23 +1,24 @@
/* 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 {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {Permission, PermissionType} from "@/interfaces/permissions"; import { Permission, PermissionType } from "@/interfaces/permissions";
import {getPermissionDoc} from "@/utils/permissions.be"; import { getPermissionDoc } from "@/utils/permissions.be";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout"; import { LayoutContext } from "@/components/High/Layout";
import {getUsers} from "@/utils/users.be"; import { getUsers } from "@/utils/users.be";
import {BsTrash} from "react-icons/bs"; import { BsTrash } from "react-icons/bs";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import axios from "axios"; import axios from "axios";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import {Type as UserType} from "@/interfaces/user"; import { Type as UserType } from "@/interfaces/user";
import {getGroups} from "@/utils/groups.be"; import { getGroups } from "@/utils/groups.be";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
import { G } from "@react-pdf/renderer";
interface BasicUser { interface BasicUser {
id: string; id: string;
name: string; name: string;
@@ -30,46 +31,48 @@ interface PermissionWithBasicUsers {
users: BasicUser[]; users: BasicUser[];
} }
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
if (!params?.id) return redirect("/permissions") if (!params?.id) return redirect("/permissions");
// Fetch data from external API // Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string); const [permission, users, groups] = await Promise.all([
getPermissionDoc(params.id as string),
const allUserData: User[] = await getUsers(); getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
const groups = await getGroups(); getGroups(),
]);
const userGroups = groups.filter((x) => x.admin === user.id); const userGroups = groups.filter((x) => x.admin === user.id);
const userGroupsParticipants = userGroups.flatMap((x) => x.participants);
const filteredGroups = const filteredGroups =
user.type === "corporate" user.type === "corporate"
? userGroups ? userGroups
: user.type === "mastercorporate" : user.type === "mastercorporate"
? groups.filter((x) => userGroups.flatMap((y) => y.participants).includes(x.admin)) ? groups.filter((x) => userGroupsParticipants.includes(x.admin))
: groups; : groups;
const filteredGroupsParticipants = filteredGroups.flatMap(
const users = allUserData.map((u) => ({ (g) => g.participants
id: u.id, );
name: u.name,
type: u.type,
})) as BasicUser[];
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type) const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) => filteredGroups.flatMap((g) => g.participants).includes(u.id)) ? users.filter((u) => filteredGroupsParticipants.includes(u.id))
: users; : users;
// const res = await fetch("api/permissions"); // const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json(); // const permissions: Permission[] = await res.json();
// Pass data to the page via props // Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce((acc: BasicUser[], userId) => { const usersData: BasicUser[] = permission.users.reduce(
(acc: BasicUser[], userId) => {
const user = filteredUsers.find((u) => u.id === userId) as BasicUser; const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
if (!!user) acc.push(user); if (!!user) acc.push(user);
return acc; return acc;
}, []); },
[]
);
return { return {
props: { props: {
@@ -83,7 +86,9 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
users: filteredUsers, users: filteredUsers,
}, },
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
permission: PermissionWithBasicUsers; permission: PermissionWithBasicUsers;
@@ -92,9 +97,11 @@ interface Props {
} }
export default function Page(props: Props) { export default function Page(props: Props) {
const {permission, user, users} = props; const { permission, user, users } = props;
const [selectedUsers, setSelectedUsers] = useState<string[]>(() => permission.users.map((u) => u.id)); const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
permission.users.map((u) => u.id)
);
const onChange = (value: any) => { const onChange = (value: any) => {
setSelectedUsers((prev) => { setSelectedUsers((prev) => {
@@ -119,6 +126,13 @@ export default function Page(props: Props) {
} }
}; };
const { setClassName } = React.useContext(LayoutContext);
useEffect(() => {
setClassName("gap-6");
return () => setClassName("");
}, [setClassName]);
return ( return (
<> <>
<Head> <Head>
@@ -131,18 +145,22 @@ export default function Page(props: Props) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="gap-6"> <>
<div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl"> <div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl">
<h1 className="text-2xl font-semibold">Permission: {permission.type as string}</h1> <h1 className="text-2xl font-semibold">
Permission: {permission.type as string}
</h1>
<div className="flex gap-3"> <div className="flex gap-3">
<Select <Select
value={null} value={null}
options={users options={users.reduce<{ label: string; value: string }[]>(
.filter((u) => !selectedUsers.includes(u.id)) (acc, u) => {
.map((u) => ({ if (!selectedUsers.includes(u.id))
label: `${u?.type}-${u?.name}`, acc.push({ label: `${u?.type}-${u?.name}`, value: u.id });
value: u.id, return acc;
}))} },
[]
)}
onChange={onChange} onChange={onChange}
/> />
<Button onClick={update}>Update</Button> <Button onClick={update}>Update</Button>
@@ -154,11 +172,18 @@ export default function Page(props: Props) {
{selectedUsers.map((userId) => { {selectedUsers.map((userId) => {
const user = users.find((u) => u.id === userId); const user = users.find((u) => u.id === userId);
return ( return (
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={userId}> <div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={userId}
>
<span className="text-base first-letter:uppercase"> <span className="text-base first-letter:uppercase">
{user?.type}-{user?.name} {user?.type}-{user?.name}
</span> </span>
<BsTrash style={{cursor: "pointer"}} onClick={() => removeUser(userId)} size={20} /> <BsTrash
style={{ cursor: "pointer" }}
onClick={() => removeUser(userId)}
size={20}
/>
</div> </div>
); );
})} })}
@@ -167,22 +192,25 @@ export default function Page(props: Props) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2>Whitelisted Users</h2> <h2>Whitelisted Users</h2>
<div className="flex flex-col gap-3 flex-wrap"> <div className="flex flex-col gap-3 flex-wrap">
{users {users.map((user) => {
.filter((user) => !selectedUsers.includes(user.id)) if (!selectedUsers.includes(user.id))
.map((user) => {
return ( return (
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={user.id}> <div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={user.id}
>
<span className="text-base first-letter:uppercase"> <span className="text-base first-letter:uppercase">
{user?.type}-{user?.name} {user?.type}-{user?.name}
</span> </span>
</div> </div>
); );
return null;
})} })}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -1,29 +1,38 @@
/* 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 {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {Permission} from "@/interfaces/permissions"; import { Permission } from "@/interfaces/permissions";
import {getPermissionDocs} from "@/utils/permissions.be"; import { getPermissionDocs } from "@/utils/permissions.be";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout"; import { LayoutContext } from "@/components/High/Layout";
import PermissionList from "@/components/PermissionList"; import PermissionList from "@/components/PermissionList";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
import React from "react";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
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("/");
// Fetch data from external API // Fetch data from external API
const permissions: Permission[] = await getPermissionDocs(); const permissions: Permission[] = await getPermissionDocs();
const filteredPermissions = permissions.filter((p) => { const filteredPermissions = permissions.filter((p) => {
const permissionType = p.type.toString().toLowerCase(); const permissionType = p.type.toString().toLowerCase();
if (user.type === "corporate") return !permissionType.includes("corporate") && !permissionType.includes("admin"); if (user.type === "corporate")
if (user.type === "mastercorporate") return !permissionType.includes("mastercorporate") && !permissionType.includes("admin"); return (
!permissionType.includes("corporate") &&
!permissionType.includes("admin")
);
if (user.type === "mastercorporate")
return (
!permissionType.includes("mastercorporate") &&
!permissionType.includes("admin")
);
return true; return true;
}); });
@@ -35,7 +44,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
props: { props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })), // permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permissions: filteredPermissions.map((p) => { permissions: filteredPermissions.map((p) => {
const {users, ...rest} = p; const { users, ...rest } = p;
return rest; return rest;
}), }),
user, user,
@@ -49,7 +58,10 @@ interface Props {
} }
export default function Page(props: Props) { export default function Page(props: Props) {
const {permissions, user} = props; const { permissions, user } = props;
const { setClassName } = React.useContext(LayoutContext);
React.useEffect(() => setClassName("gap-6"), [setClassName]);
return ( return (
<> <>
@@ -62,12 +74,12 @@ export default function Page(props: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user} className="gap-6"> <>
<h1 className="text-2xl font-semibold">Permissions</h1> <h1 className="text-2xl font-semibold">Permissions</h1>
<div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl"> <div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
<PermissionList permissions={permissions} /> <PermissionList permissions={permissions} />
</div> </div>
</Layout> </>
</> </>
); );
} }

View File

@@ -2,10 +2,16 @@
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 { ChangeEvent, Dispatch, ReactNode, SetStateAction, useEffect, useRef, useState } from "react"; import {
ChangeEvent,
Dispatch,
ReactNode,
SetStateAction,
useRef,
useState,
} from "react";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Link from "next/link"; import Link from "next/link";
@@ -15,26 +21,21 @@ import clsx from "clsx";
import { import {
CorporateUser, CorporateUser,
EmploymentStatus, EmploymentStatus,
EMPLOYMENT_STATUS,
Gender, Gender,
User, User,
DemographicInformation, DemographicInformation,
MasterCorporateUser, MasterCorporateUser,
Group,
} from "@/interfaces/user"; } from "@/interfaces/user";
import CountrySelect from "@/components/Low/CountrySelect"; import CountrySelect from "@/components/Low/CountrySelect";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import moment from "moment"; import moment from "moment";
import { BsCamera, BsQuestionCircleFill } from "react-icons/bs"; import { BsCamera, BsQuestionCircleFill } from "react-icons/bs";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import { convertBase64, redirect } from "@/utils"; import { convertBase64, redirect } from "@/utils";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import GenderInput from "@/components/High/GenderInput"; import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput"; import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
import TimezoneSelect from "@/components/Low/TImezoneSelect"; import TimezoneSelect from "@/components/Low/TImezoneSelect";
import Modal from "@/components/Modal";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector"; import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
@@ -42,39 +43,65 @@ import { InstructorGender } from "@/interfaces/exam";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import TopicModal from "@/components/Medium/TopicModal"; import TopicModal from "@/components/Medium/TopicModal";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getParticipantGroups, getUserCorporate } from "@/utils/groups.be"; import { getParticipantGroups, getUserCorporate } from "@/utils/groups.be";
import { InferGetServerSidePropsType } from "next"; import { countUsers, getUser } from "@/utils/users.be";
import { getUsers } from "@/utils/users.be";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
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 [linkedCorporate, groups, referralAgent] = await Promise.all([
getUserCorporate(user.id) || null,
getParticipantGroups(user.id, { _id: 0, group: 1 }),
user.type === "corporate" && user.corporateInformation.referralAgent
? getUser(user.corporateInformation.referralAgent, {
_id: 0,
name: 1,
email: 1,
demographicInformation: 1,
})
: null,
]);
const groupsAdmin = groups.map((group) => group.admin);
const hasBenefitsFromUniversity =
(await countUsers({
id: { $in: groupsAdmin },
type: "corporate",
})) > 0;
return { return {
props: { props: {
user, user,
linkedCorporate: (await getUserCorporate(user.id)) || null, linkedCorporate,
groups: await getParticipantGroups(user.id), hasBenefitsFromUniversity,
users: await getUsers(), referralAgent,
}, },
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
groups: Group[]; hasBenefitsFromUniversity: boolean;
users: User[];
mutateUser: Function; mutateUser: Function;
referralAgent?: User;
linkedCorporate?: CorporateUser | MasterCorporateUser; linkedCorporate?: CorporateUser | MasterCorporateUser;
} }
const DoubleColumnRow = ({ children }: { children: ReactNode }) => <div className="flex flex-col lg:flex-row gap-8 w-full">{children}</div>; const DoubleColumnRow = ({ children }: { children: ReactNode }) => (
<div className="flex flex-col lg:flex-row gap-8 w-full">{children}</div>
);
function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props) { function UserProfile({
user,
mutateUser,
linkedCorporate,
hasBenefitsFromUniversity,
referralAgent,
}: Props) {
const [bio, setBio] = useState(user.bio || ""); const [bio, setBio] = useState(user.bio || "");
const [name, setName] = useState(user.name || ""); const [name, setName] = useState(user.name || "");
const [email, setEmail] = useState(user.email || ""); const [email, setEmail] = useState(user.email || "");
@@ -83,37 +110,69 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [profilePicture, setProfilePicture] = useState(user.profilePicture); const [profilePicture, setProfilePicture] = useState(user.profilePicture);
const [desiredLevels, setDesiredLevels] = useState(checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined); const [desiredLevels, setDesiredLevels] = useState(
checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined
);
const [focus, setFocus] = useState<"academic" | "general">(user.focus); const [focus, setFocus] = useState<"academic" | "general">(user.focus);
const [country, setCountry] = useState<string>(user.demographicInformation?.country || ""); const [country, setCountry] = useState<string>(
const [phone, setPhone] = useState<string>(user.demographicInformation?.phone || ""); user.demographicInformation?.country || ""
const [gender, setGender] = useState<Gender | undefined>(user.demographicInformation?.gender || undefined); );
const [phone, setPhone] = useState<string>(
user.demographicInformation?.phone || ""
);
const [gender, setGender] = useState<Gender | undefined>(
user.demographicInformation?.gender || undefined
);
const [employment, setEmployment] = useState<EmploymentStatus | undefined>( const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
checkAccess(user, ["corporate", "mastercorporate"]) ? undefined : (user.demographicInformation as DemographicInformation)?.employment, checkAccess(user, ["corporate", "mastercorporate"])
? undefined
: (user.demographicInformation as DemographicInformation)?.employment
); );
const [passport_id, setPassportID] = useState<string | undefined>( const [passport_id, setPassportID] = useState<string | undefined>(
checkAccess(user, ["student"]) ? (user.demographicInformation as DemographicInformation)?.passport_id : undefined, checkAccess(user, ["student"])
? (user.demographicInformation as DemographicInformation)?.passport_id
: undefined
); );
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>( const [preferredGender, setPreferredGender] = useState<
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined, InstructorGender | undefined
>(
user.type === "student" || user.type === "developer"
? user.preferredGender || "varied"
: undefined
); );
const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>( const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>(
user.type === "student" || user.type === "developer" ? user.preferredTopics : undefined, user.type === "student" || user.type === "developer"
? user.preferredTopics
: undefined
); );
const [position, setPosition] = useState<string | undefined>( const [position, setPosition] = useState<string | undefined>(
user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined, user.type === "corporate" || user.type === "mastercorporate"
? user.demographicInformation?.position
: undefined
); );
const [corporateInformation, setCorporateInformation] = useState( const [corporateInformation, setCorporateInformation] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation : undefined, user.type === "corporate" || user.type === "mastercorporate"
? user.corporateInformation
: undefined
); );
const [companyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined); const [companyName] = useState<string | undefined>(
const [commercialRegistration] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined); user.type === "agent" ? user.agentInformation?.companyName : undefined
const [arabName, setArabName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyArabName : undefined); );
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess()); const [commercialRegistration] = useState<string | undefined>(
user.type === "agent"
? user.agentInformation?.commercialRegistration
: undefined
);
const [arabName, setArabName] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.companyArabName : undefined
);
const [timezone, setTimezone] = useState<string>(
user.demographicInformation?.timezone || moment.tz.guess()
);
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false); const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
const profilePictureInput = useRef(null); const profilePictureInput = useRef(null);
@@ -121,9 +180,12 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(1, "days").isAfter(momentDate))
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
}; };
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => { const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
@@ -143,15 +205,15 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
} }
if (newPassword && !password) { if (newPassword && !password) {
toast.error("To update your password you need to input your current one!"); toast.error(
"To update your password you need to input your current one!"
);
setIsLoading(false); setIsLoading(false);
return; return;
} }
if (email !== user?.email) { if (email !== user?.email) {
const userAdmins = groups.filter((x) => x.participants.includes(user.id)).map((x) => x.admin); const message = hasBenefitsFromUniversity
const message =
users.filter((x) => userAdmins.includes(x.id) && x.type === "corporate").length > 0
? "If you change your e-mail address, you will lose all benefits from your university/institute. Are you sure you want to continue?" ? "If you change your e-mail address, you will lose all benefits from your university/institute. Are you sure you want to continue?"
: "Are you sure you want to update your e-mail address?"; : "Are you sure you want to update your e-mail address?";
@@ -212,7 +274,9 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
const ExpirationDate = () => ( const ExpirationDate = () => (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label> <label className="font-normal text-base text-mti-gray-dim">
Expiry Date (click to purchase)
</label>
<Link <Link
href="/payment" href="/payment"
className={clsx( className={clsx(
@@ -221,33 +285,45 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
!user.subscriptionExpirationDate !user.subscriptionExpirationDate
? "!bg-mti-green-ultralight !border-mti-green-light" ? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate), : expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum", "bg-white border-mti-gray-platinum"
)}> )}
>
{!user.subscriptionExpirationDate && "Unlimited"} {!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")} {user.subscriptionExpirationDate &&
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link> </Link>
</div> </div>
); );
const TimezoneInput = () => ( const TimezoneInput = () => (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Timezone</label> <label className="font-normal text-base text-mti-gray-dim">
Timezone
</label>
<TimezoneSelect value={timezone} onChange={setTimezone} /> <TimezoneSelect value={timezone} onChange={setTimezone} />
</div> </div>
); );
const manualDownloadLink = ["student", "teacher", "corporate"].includes(user.type) ? `/manuals/${user.type}.pdf` : ""; const manualDownloadLink = ["student", "teacher", "corporate"].includes(
user.type
)
? `/manuals/${user.type}.pdf`
: "";
return ( return (
<Layout user={user}> <>
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8"> <section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
<h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1> <h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1>
<div className="flex -md:flex-col-reverse -md:items-center w-full justify-between"> <div className="flex -md:flex-col-reverse -md:items-center w-full justify-between">
<div className="flex flex-col gap-8 w-full md:w-2/3"> <div className="flex flex-col gap-8 w-full md:w-2/3">
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1> <h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
<form className="flex flex-col items-center gap-6 w-full" onSubmit={(e) => e.preventDefault()}> <form
className="flex flex-col items-center gap-6 w-full"
onSubmit={(e) => e.preventDefault()}
>
<DoubleColumnRow> <DoubleColumnRow>
{user.type !== "corporate" && user.type !== "mastercorporate" && ( {user.type !== "corporate" &&
user.type !== "mastercorporate" && (
<Input <Input
label={user.type === "agent" ? "English name" : "Name"} label={user.type === "agent" ? "English name" : "Name"}
type="text" type="text"
@@ -323,7 +399,9 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<DoubleColumnRow> <DoubleColumnRow>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<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 <Input
@@ -356,26 +434,37 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<Divider /> <Divider />
{desiredLevels && ["developer", "student"].includes(user.type) && ( {desiredLevels &&
["developer", "student"].includes(user.type) && (
<> <>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Desired Levels</label> <label className="font-normal text-base text-mti-gray-dim">
Desired Levels
</label>
<ModuleLevelSelector <ModuleLevelSelector
levels={desiredLevels} levels={desiredLevels}
setLevels={setDesiredLevels as Dispatch<SetStateAction<{ [key in Module]: number }>>} setLevels={
setDesiredLevels as Dispatch<
SetStateAction<{ [key in Module]: number }>
>
}
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Focus</label> <label className="font-normal text-base text-mti-gray-dim">
Focus
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 w-full"> <div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 w-full">
<button <button
onClick={() => setFocus("academic")} onClick={() => setFocus("academic")}
className={clsx( className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white", "w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white", "hover:bg-mti-purple-light hover:text-white",
focus === "academic" && "!bg-mti-purple-light !text-white", focus === "academic" &&
"transition duration-300 ease-in-out", "!bg-mti-purple-light !text-white",
)}> "transition duration-300 ease-in-out"
)}
>
Academic Academic
</button> </button>
<button <button
@@ -383,9 +472,11 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
className={clsx( className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white", "w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white", "hover:bg-mti-purple-light hover:text-white",
focus === "general" && "!bg-mti-purple-light !text-white", focus === "general" &&
"transition duration-300 ease-in-out", "!bg-mti-purple-light !text-white",
)}> "transition duration-300 ease-in-out"
)}
>
General General
</button> </button>
</div> </div>
@@ -393,18 +484,27 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
</> </>
)} )}
{preferredGender && ["developer", "student"].includes(user.type) && ( {preferredGender &&
["developer", "student"].includes(user.type) && (
<> <>
<Divider /> <Divider />
<DoubleColumnRow> <DoubleColumnRow>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select <Select
value={{ value={{
value: preferredGender, value: preferredGender,
label: capitalize(preferredGender), label: capitalize(preferredGender),
}} }}
onChange={(value) => (value ? setPreferredGender(value.value as InstructorGender) : null)} onChange={(value) =>
value
? setPreferredGender(
value.value as InstructorGender
)
: null
}
options={[ options={[
{ value: "male", label: "Male" }, { value: "male", label: "Male" },
{ value: "female", label: "Female" }, { value: "female", label: "Female" },
@@ -417,12 +517,18 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
Preferred Topics{" "} Preferred Topics{" "}
<span <span
className="tooltip" className="tooltip"
data-tip="These topics will be considered for speaking and writing modules, aiming to include at least one exercise containing of the these in the selected exams."> data-tip="These topics will be considered for speaking and writing modules, aiming to include at least one exercise containing of the these in the selected exams."
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</span> </span>
</label> </label>
<Button className="w-full" variant="outline" onClick={() => setIsPreferredTopicsOpen(true)}> <Button
Select Topics ({preferredTopics?.length || "All"} selected) className="w-full"
variant="outline"
onClick={() => setIsPreferredTopicsOpen(true)}
>
Select Topics ({preferredTopics?.length || "All"}{" "}
selected)
</Button> </Button>
</div> </div>
</DoubleColumnRow> </DoubleColumnRow>
@@ -483,14 +589,16 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
</> </>
)} )}
{user.type === "corporate" && user.corporateInformation.referralAgent && ( {user.type === "corporate" &&
user.corporateInformation.referralAgent &&
referralAgent && (
<> <>
<Divider /> <Divider />
<DoubleColumnRow> <DoubleColumnRow>
<Input <Input
name="agentName" name="agentName"
onChange={() => null} onChange={() => null}
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.name} defaultValue={referralAgent?.name}
type="text" type="text"
label="Country Manager's Name" label="Country Manager's Name"
placeholder="Not available" placeholder="Not available"
@@ -500,7 +608,7 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<Input <Input
name="agentEmail" name="agentEmail"
onChange={() => null} onChange={() => null}
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.email} defaultValue={referralAgent?.email}
type="text" type="text"
label="Country Manager's E-mail" label="Country Manager's E-mail"
placeholder="Not available" placeholder="Not available"
@@ -510,12 +618,11 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
</DoubleColumnRow> </DoubleColumnRow>
<DoubleColumnRow> <DoubleColumnRow>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country Manager&apos;s Country *</label> <label className="font-normal text-base text-mti-gray-dim">
Country Manager&apos;s Country *
</label>
<CountrySelect <CountrySelect
value={ value={referralAgent?.demographicInformation?.country}
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation
?.country
}
onChange={() => null} onChange={() => null}
disabled disabled
/> />
@@ -528,7 +635,7 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
onChange={() => null} onChange={() => null}
placeholder="Not available" placeholder="Not available"
defaultValue={ defaultValue={
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation?.phone referralAgent?.demographicInformation?.phone
} }
disabled disabled
required required
@@ -539,7 +646,10 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
{user.type !== "corporate" && ( {user.type !== "corporate" && (
<DoubleColumnRow> <DoubleColumnRow>
<EmploymentStatusInput value={employment} onChange={setEmployment} /> <EmploymentStatusInput
value={employment}
onChange={setEmployment}
/>
<div className="flex flex-col gap-8 w-full"> <div className="flex flex-col gap-8 w-full">
<GenderInput value={gender} onChange={setGender} /> <GenderInput value={gender} onChange={setGender} />
@@ -552,37 +662,62 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<div className="flex flex-col gap-6 w-48"> <div className="flex flex-col gap-6 w-48">
<div <div
className="flex flex-col gap-3 items-center h-fit cursor-pointer group" className="flex flex-col gap-3 items-center h-fit cursor-pointer group"
onClick={() => (profilePictureInput.current as any)?.click()}> onClick={() => (profilePictureInput.current as any)?.click()}
>
<div className="relative overflow-hidden h-48 w-48 rounded-full"> <div className="relative overflow-hidden h-48 w-48 rounded-full">
<div <div
className={clsx( className={clsx(
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100", "absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
"transition ease-in-out duration-300", "transition ease-in-out duration-300"
)}> )}
>
<BsCamera className="text-6xl text-mti-purple-ultralight/80" /> <BsCamera className="text-6xl text-mti-purple-ultralight/80" />
</div> </div>
<img src={profilePicture} alt={user.name} className="aspect-square drop-shadow-xl self-end object-cover" /> <img
src={profilePicture}
alt={user.name}
className="aspect-square drop-shadow-xl self-end object-cover"
/>
</div> </div>
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} /> <input
type="file"
className="hidden"
onChange={uploadProfilePicture}
accept="image/*"
ref={profilePictureInput}
/>
<span <span
onClick={() => (profilePictureInput.current as any)?.click()} onClick={() => (profilePictureInput.current as any)?.click()}
className="cursor-pointer text-mti-purple-light text-sm"> className="cursor-pointer text-mti-purple-light text-sm"
>
Change picture Change picture
</span> </span>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6> <h6 className="font-normal text-base text-mti-gray-taupe">
{USER_TYPE_LABELS[user.type]}
</h6>
</div> </div>
{user.type === "agent" && ( {user.type === "agent" && (
<div className="flag items-center h-fit"> <div className="flag items-center h-fit">
<img <img
alt={user.demographicInformation?.country.toLowerCase() + "_flag"} alt={
user.demographicInformation?.country.toLowerCase() + "_flag"
}
src={`https://flagcdn.com/w320/${user.demographicInformation?.country.toLowerCase()}.png`} src={`https://flagcdn.com/w320/${user.demographicInformation?.country.toLowerCase()}.png`}
width="320" width="320"
/> />
</div> </div>
)} )}
{manualDownloadLink && ( {manualDownloadLink && (
<a href={manualDownloadLink} className="max-w-[200px] self-end w-full" download> <a
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full"> href={manualDownloadLink}
className="max-w-[200px] self-end w-full"
download
>
<Button
color="purple"
variant="outline"
className="max-w-[200px] self-end w-full"
>
Download Manual Download Manual
</Button> </Button>
</a> </a>
@@ -601,20 +736,33 @@ function UserProfile({ user, mutateUser, linkedCorporate, groups, users }: Props
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Link href="/" className="max-w-[200px] self-end w-full"> <Link href="/" className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full"> <Button
color="purple"
variant="outline"
className="max-w-[200px] self-end w-full"
>
Back Back
</Button> </Button>
</Link> </Link>
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}> <Button
color="purple"
className="max-w-[200px] self-end w-full"
onClick={updateUser}
disabled={isLoading}
>
Save Changes Save Changes
</Button> </Button>
</div> </div>
</section> </section>
</Layout> </>
); );
} }
export default function Home(props: { linkedCorporate?: CorporateUser | MasterCorporateUser; groups: Group[]; users: User[] }) { export default function Home(props: {
hasBenefitsFromUniversity: boolean;
referralAgent?: User;
linkedCorporate?: CorporateUser | MasterCorporateUser;
}) {
const { user, mutateUser } = useUser({ redirectTo: "/login" }); const { user, mutateUser } = useUser({ redirectTo: "/login" });
return ( return (

View File

@@ -8,7 +8,6 @@ import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { groupByDate } from "@/utils/stats"; import { groupByDate } from "@/utils/stats";
import moment from "moment"; import moment from "moment";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { uuidv4 } from "@firebase/util"; import { uuidv4 } from "@firebase/util";
@@ -21,33 +20,49 @@ import useTrainingContentStore from "@/stores/trainingContentStore";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import useGradingSystem from "@/hooks/useGrading";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; import {
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be"; getGradingSystemByEntities,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces"; import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import getPendingEvals from "@/utils/disabled.be";
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
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 isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) const entities = await getEntitiesWithRoles(isAdmin ? undefined : entityIDs);
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id'))) const entitiesIds = mapBy(entities, "id");
const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id'))) const [users, assignments, gradingSystems, pendingSessionIds] =
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id')) await Promise.all([
isAdmin ? getUsers() : getEntitiesUsers(entitiesIds),
isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds),
getGradingSystemByEntities(entitiesIds),
getPendingEvals(user.id),
]);
return { return {
props: serialize({ user, users, assignments, entities, gradingSystems }), props: serialize({
user,
users,
assignments,
entities,
gradingSystems,
isAdmin,
pendingSessionIds,
}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -57,42 +72,67 @@ interface Props {
user: User; user: User;
users: User[]; users: User[];
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
gradingSystems: Grading[] gradingSystems: Grading[];
pendingSessionIds: string[];
isAdmin: boolean;
} }
const MAX_TRAINING_EXAMS = 10; const MAX_TRAINING_EXAMS = 10;
export default function History({ user, users, assignments, entities, gradingSystems }: Props) { export default function History({
user,
users,
assignments,
entities,
gradingSystems,
isAdmin,
pendingSessionIds,
}: Props) {
const router = useRouter(); const router = useRouter();
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore(
(state) => [
state.selectedUser, state.selectedUser,
state.setSelectedUser, state.setSelectedUser,
state.training, state.training,
state.setTraining, state.setTraining,
]); ]
);
const [filter, setFilter] = useState<Filter>(); const [filter, setFilter] = useState<Filter>();
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id); const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record') Stat[]
>(statsUserId || user?.id);
const allowedDownloadEntities = useAllowedEntities(
user,
entities,
"download_student_record"
);
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]); const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>(
[]
);
const setTrainingStats = useTrainingContentStore((state) => state.setStats); const setTrainingStats = useTrainingContentStore((state) => state.setStats);
const groupedStats = useMemo(() => groupByDate( const groupedStats = useMemo(
() =>
groupByDate(
stats.filter((x) => { stats.filter((x) => {
if ( if (
(x.module === "writing" || x.module === "speaking") && (x.module === "writing" || x.module === "speaking") &&
!x.isDisabled && !x.isDisabled &&
Array.isArray(x.solutions) &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation")) !x.solutions.every((y) => Object.keys(y).includes("evaluation"))
) )
return false; return false;
return true; return true;
}), })
), [stats]) ),
[stats]
);
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]); useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
@@ -114,7 +154,8 @@ export default function History({ user, users, assignments, entities, gradingSys
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp]; if (timestamp >= filterDate)
filteredStats[timestamp] = stats[timestamp];
}); });
return filteredStats; return filteredStats;
} }
@@ -123,8 +164,14 @@ export default function History({ user, users, assignments, entities, gradingSys
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false)) if (
filteredStats[timestamp] = [...stats[timestamp].filter((s) => !!s.assignment)]; stats[timestamp]
.map((s) => s.assignment === undefined)
.includes(false)
)
filteredStats[timestamp] = [
...stats[timestamp].filter((s) => !!s.assignment),
];
}); });
return filteredStats; return filteredStats;
@@ -137,9 +184,14 @@ export default function History({ user, users, assignments, entities, gradingSys
if (groupedStats) { if (groupedStats) {
const groupedStatsByDate = filterStatsByDate(groupedStats); const groupedStatsByDate = filterStatsByDate(groupedStats);
const allStats = Object.keys(groupedStatsByDate); const allStats = Object.keys(groupedStatsByDate);
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => { const selectedStats = selectedTrainingExams.reduce<
Record<string, Stat[]>
>((accumulator, moduleAndTimestamp) => {
const timestamp = moduleAndTimestamp.split("-")[1]; const timestamp = moduleAndTimestamp.split("-")[1];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) { if (
allStats.includes(timestamp) &&
!accumulator.hasOwnProperty(timestamp)
) {
accumulator[timestamp] = groupedStatsByDate[timestamp]; accumulator[timestamp] = groupedStatsByDate[timestamp];
} }
return accumulator; return accumulator;
@@ -149,17 +201,22 @@ export default function History({ user, users, assignments, entities, gradingSys
} }
}; };
const filteredStats = useMemo(() => const filteredStats = useMemo(
Object.keys(filterStatsByDate(groupedStats)) () =>
.sort((a, b) => parseInt(b) - parseInt(a)), Object.keys(filterStatsByDate(groupedStats)).sort(
(a, b) => parseInt(b) - parseInt(a)
),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[groupedStats, filter]) [groupedStats, filter]
);
const customContent = (timestamp: string) => { const customContent = (timestamp: string) => {
const dateStats = groupedStats[timestamp]; const dateStats = groupedStats[timestamp];
const statUser = findBy(users, 'id', dateStats[0]?.user) const statUser = findBy(users, "id", dateStats[0]?.user);
const canDownload = mapBy(statUser?.entities, 'id').some(e => mapBy(allowedDownloadEntities, 'id').includes(e)) const canDownload = mapBy(statUser?.entities, "id").some((e) =>
mapBy(allowedDownloadEntities, "id").includes(e)
);
return ( return (
<StatsGridItem <StatsGridItem
@@ -179,6 +236,12 @@ export default function History({ user, users, assignments, entities, gradingSys
); );
}; };
useEvaluationPolling(
pendingSessionIds ? pendingSessionIds : [],
"records",
user.id
);
return ( return (
<> <>
<Head> <Head>
@@ -192,8 +255,13 @@ export default function History({ user, users, assignments, entities, gradingSys
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user}> <>
<RecordFilter user={user} users={users} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}> <RecordFilter
user={user}
isAdmin={isAdmin}
entities={entities}
filterState={{ filter: filter, setFilter: setFilter }}
>
{training && ( {training && (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="font-semibold text-2xl mr-4"> <div className="font-semibold text-2xl mr-4">
@@ -203,19 +271,25 @@ export default function History({ user, users, assignments, entities, gradingSys
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
disabled={selectedTrainingExams.length == 0} disabled={selectedTrainingExams.length == 0}
onClick={handleTrainingContentSubmission}> onClick={handleTrainingContentSubmission}
>
Submit Submit
</button> </button>
</div> </div>
)} )}
</RecordFilter> </RecordFilter>
{filteredStats.length > 0 && !isStatsLoading && ( {filteredStats.length > 0 && !isStatsLoading && (
<CardList list={filteredStats} renderCard={customContent} searchFields={[]} pageSize={30} className="lg:!grid-cols-3" /> <CardList
list={filteredStats}
renderCard={customContent}
searchFields={[]}
pageSize={30}
className="lg:!grid-cols-3"
/>
)} )}
{filteredStats.length === 0 && !isStatsLoading && ( {filteredStats.length === 0 && !isStatsLoading && (
<span className="font-semibold ml-1">No record to display...</span> <span className="font-semibold ml-1">No record to display...</span>
@@ -225,7 +299,7 @@ export default function History({ user, users, assignments, entities, gradingSys
<span className="loading loading-infinity w-32 bg-mti-green-light" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
</div> </div>
)} )}
</Layout> </>
)} )}
</> </>
); );

View File

@@ -8,9 +8,7 @@ import clsx from "clsx";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import RegisterIndividual from "./(register)/RegisterIndividual"; import RegisterIndividual from "./(register)/RegisterIndividual";
import RegisterCorporate from "./(register)/RegisterCorporate"; import RegisterCorporate from "./(register)/RegisterCorporate";
import EmailVerification from "./(auth)/EmailVerification";
import {sendEmailVerification} from "@/utils/email"; import {sendEmailVerification} from "@/utils/email";
import useUsers from "@/hooks/useUsers";
import axios from "axios"; import axios from "axios";
export const getServerSideProps = (context: any) => { export const getServerSideProps = (context: any) => {

Some files were not shown because too many files have changed in this diff Show More