Merge branches 'main' and 'develop' of bitbucket.org:ecropdev/ielts-ui into develop
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,4 +40,6 @@ next-env.d.ts
|
||||
.env
|
||||
.yarn/*
|
||||
.history*
|
||||
__ENV.js
|
||||
__ENV.js
|
||||
|
||||
settings.json
|
||||
32
src/components/ApprovalWorkflows/RequestedBy.tsx
Normal file
32
src/components/ApprovalWorkflows/RequestedBy.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
src/components/ApprovalWorkflows/StartedOn.tsx
Normal file
41
src/components/ApprovalWorkflows/StartedOn.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/components/ApprovalWorkflows/Status.tsx
Normal file
23
src/components/ApprovalWorkflows/Status.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
src/components/ApprovalWorkflows/Tip.tsx
Normal file
14
src/components/ApprovalWorkflows/Tip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/ApprovalWorkflows/UserWithProfilePic.tsx
Normal file
24
src/components/ApprovalWorkflows/UserWithProfilePic.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
};
|
||||
203
src/components/ApprovalWorkflows/WorkflowForm.tsx
Normal file
203
src/components/ApprovalWorkflows/WorkflowForm.tsx
Normal 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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
101
src/components/ApprovalWorkflows/WorkflowStepComponent.tsx
Normal file
101
src/components/ApprovalWorkflows/WorkflowStepComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src/components/ApprovalWorkflows/WorkflowStepNumber.tsx
Normal file
31
src/components/ApprovalWorkflows/WorkflowStepNumber.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
src/components/ApprovalWorkflows/WorkflowStepSelects.tsx
Normal file
51
src/components/ApprovalWorkflows/WorkflowStepSelects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
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="flex flex-col gap-4">
|
||||
<Dropdown
|
||||
|
||||
@@ -17,6 +17,7 @@ import ListeningComponents from "./listening/components";
|
||||
import ReadingComponents from "./reading/components";
|
||||
import SpeakingComponents from "./speaking/components";
|
||||
import SectionPicker from "./Shared/SectionPicker";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
|
||||
|
||||
const LevelSettings: React.FC = () => {
|
||||
@@ -194,7 +195,7 @@ const LevelSettings: React.FC = () => {
|
||||
category: s.settings.category
|
||||
};
|
||||
}).filter(part => part.exercises.length > 0),
|
||||
isDiagnostic: false,
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
@@ -213,6 +214,36 @@ const LevelSettings: React.FC = () => {
|
||||
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) {
|
||||
console.error('Error submitting exam:', error);
|
||||
toast.error(
|
||||
|
||||
@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||
>
|
||||
<div className="flex flex-row 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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
|
||||
@@ -17,6 +17,7 @@ import { usePersistentExamStore } from "@/stores/exam";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import ListeningComponents from "./components";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
|
||||
const ListeningSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -137,7 +138,7 @@ const ListeningSettings: React.FC = () => {
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
@@ -151,6 +152,36 @@ const ListeningSettings: React.FC = () => {
|
||||
playSound("sent");
|
||||
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 {
|
||||
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
||||
}
|
||||
|
||||
@@ -5,103 +5,140 @@ import ExercisePicker from "../../ExercisePicker";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
localSettings: ReadingSectionSettings | LevelSectionSettings;
|
||||
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
|
||||
currentSection: ReadingPart | LevelPart;
|
||||
generatePassageDisabled?: boolean;
|
||||
levelId?: number;
|
||||
level?: boolean;
|
||||
localSettings: ReadingSectionSettings | LevelSectionSettings;
|
||||
updateLocalAndScheduleGlobal: (
|
||||
updates: Partial<ReadingSectionSettings | LevelSectionSettings>,
|
||||
schedule?: boolean
|
||||
) => void;
|
||||
currentSection: ReadingPart | LevelPart;
|
||||
generatePassageDisabled?: boolean;
|
||||
levelId?: number;
|
||||
level?: boolean;
|
||||
}
|
||||
|
||||
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => {
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
const ReadingComponents: React.FC<Props> = ({
|
||||
localSettings,
|
||||
updateLocalAndScheduleGlobal,
|
||||
currentSection,
|
||||
levelId,
|
||||
level = false,
|
||||
generatePassageDisabled = false,
|
||||
}) => {
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const { focusedSection, difficulty } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule]
|
||||
);
|
||||
|
||||
const generatePassage = useCallback(() => {
|
||||
generate(
|
||||
levelId ? levelId : focusedSection,
|
||||
"reading",
|
||||
"passage",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
title: data.title,
|
||||
text: data.text
|
||||
}],
|
||||
level ? focusedSection : undefined,
|
||||
level
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
|
||||
|
||||
const onTopicChange = useCallback((readingTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ readingTopic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
title="Generate Passage"
|
||||
module="reading"
|
||||
open={localSettings.isPassageOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
||||
disabled={generatePassageDisabled}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.readingTopic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex self-end h-16 mb-1">
|
||||
<GenerateBtn
|
||||
module="reading"
|
||||
genType="passage"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generatePassage}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
title="Add Exercises"
|
||||
module="reading"
|
||||
open={localSettings.isReadingTopicOpean}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
|
||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
||||
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="reading"
|
||||
sectionId={levelId !== undefined ? levelId : focusedSection}
|
||||
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
|
||||
levelSectionId={focusedSection}
|
||||
level={level}
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
const generatePassage = useCallback(() => {
|
||||
generate(
|
||||
levelId ? levelId : focusedSection,
|
||||
"reading",
|
||||
"passage",
|
||||
{
|
||||
method: "GET",
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.readingTopic && {
|
||||
topic: localSettings.readingTopic,
|
||||
}),
|
||||
},
|
||||
},
|
||||
(data: any) => [
|
||||
{
|
||||
title: data.title,
|
||||
text: data.text,
|
||||
},
|
||||
],
|
||||
level ? focusedSection : undefined,
|
||||
level
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
|
||||
|
||||
const onTopicChange = useCallback(
|
||||
(readingTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ readingTopic });
|
||||
},
|
||||
[updateLocalAndScheduleGlobal]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
title="Generate Passage"
|
||||
module="reading"
|
||||
open={localSettings.isPassageOpen}
|
||||
setIsOpen={(isOpen: boolean) =>
|
||||
updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)
|
||||
}
|
||||
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
|
||||
disabled={generatePassageDisabled}
|
||||
>
|
||||
<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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Topic (Optional)
|
||||
</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.readingTopic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex self-end h-16 mb-1">
|
||||
<GenerateBtn
|
||||
module="reading"
|
||||
genType="passage"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generatePassage}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
title="Add Exercises"
|
||||
module="reading"
|
||||
open={localSettings.isReadingTopicOpean}
|
||||
setIsOpen={(isOpen: boolean) =>
|
||||
updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })
|
||||
}
|
||||
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
|
||||
disabled={
|
||||
currentSection === undefined ||
|
||||
currentSection.text === undefined ||
|
||||
currentSection.text.content === "" ||
|
||||
currentSection.text.title === ""
|
||||
}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="reading"
|
||||
sectionId={levelId !== undefined ? levelId : focusedSection}
|
||||
extraArgs={{
|
||||
text:
|
||||
currentSection === undefined || currentSection.text === undefined
|
||||
? ""
|
||||
: currentSection.text.content,
|
||||
}}
|
||||
levelSectionId={focusedSection}
|
||||
level={level}
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingComponents;
|
||||
|
||||
@@ -12,6 +12,7 @@ import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import ReadingComponents from "./components";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
|
||||
const ReadingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -46,15 +47,15 @@ const ReadingSettings: React.FC = () => {
|
||||
{
|
||||
label: "Preset: Reading Passage 1",
|
||||
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 2",
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 2",
|
||||
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
|
||||
},
|
||||
{
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 3",
|
||||
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const canPreviewOrSubmit = sections.some(
|
||||
@@ -75,7 +76,7 @@ const ReadingSettings: React.FC = () => {
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
@@ -89,11 +90,37 @@ const ReadingSettings: React.FC = () => {
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
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) => {
|
||||
console.log(error);
|
||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
||||
})
|
||||
if (error.response && error.response.status === 404) {
|
||||
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 = () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import openDetachedTab from "@/utils/popout";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import SpeakingComponents from "./components";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
|
||||
export interface Avatar {
|
||||
name: string;
|
||||
@@ -180,7 +181,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
@@ -194,6 +195,36 @@ const SpeakingSettings: React.FC = () => {
|
||||
Array.from(urlMap.values()).forEach(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) {
|
||||
toast.error(
|
||||
|
||||
@@ -12,6 +12,8 @@ import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import WritingComponents from "./components";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||
|
||||
const WritingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -129,7 +131,7 @@ const WritingSettings: React.FC = () => {
|
||||
minTimer,
|
||||
module: "writing",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
@@ -140,6 +142,36 @@ const WritingSettings: React.FC = () => {
|
||||
playSound("sent");
|
||||
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) {
|
||||
console.error('Error submitting exam:', error);
|
||||
toast.error(
|
||||
|
||||
@@ -19,8 +19,8 @@ const label = (type: string, firstId: string, lastId: string) => {
|
||||
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
|
||||
return (
|
||||
<div className="flex w-full justify-between items-center mr-4">
|
||||
<span className="font-semibold">{label(type, firstId, lastId)}</span>
|
||||
<div className="text-sm font-light italic">{previewLabel(prompt)}</div>
|
||||
<span className="font-semibold ellipsis-2">{label(type, firstId, lastId)}</span>
|
||||
<div className="text-sm font-light italic ellipsis-2">{previewLabel(prompt)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,216 +24,265 @@ import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
|
||||
const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
sections,
|
||||
minTimer,
|
||||
expandedSections,
|
||||
examLabel,
|
||||
isPrivate,
|
||||
difficulty,
|
||||
sectionLabels,
|
||||
importModule
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
sections,
|
||||
minTimer,
|
||||
expandedSections,
|
||||
examLabel,
|
||||
isPrivate,
|
||||
difficulty,
|
||||
sectionLabels,
|
||||
importModule,
|
||||
} = useExamEditorStore((state) => state.modules[currentModule]);
|
||||
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(
|
||||
levelParts !== 0 ? levelParts : 1
|
||||
);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
|
||||
// For exam edits
|
||||
useEffect(() => {
|
||||
if (levelParts !== 0) {
|
||||
setNumberOfLevelParts(levelParts);
|
||||
dispatch({
|
||||
type: 'UPDATE_MODULE',
|
||||
payload: {
|
||||
updates: {
|
||||
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`
|
||||
}))
|
||||
},
|
||||
module: "level"
|
||||
}
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelParts])
|
||||
// For exam edits
|
||||
useEffect(() => {
|
||||
if (levelParts !== 0) {
|
||||
setNumberOfLevelParts(levelParts);
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
})),
|
||||
},
|
||||
module: "level",
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelParts]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSections = sections;
|
||||
const currentLabels = sectionLabels;
|
||||
let updatedSections: SectionState[];
|
||||
let updatedLabels: any;
|
||||
if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) {
|
||||
const newSections = [...currentSections];
|
||||
const newLabels = [...currentLabels];
|
||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||
newLabels.push({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`
|
||||
});
|
||||
}
|
||||
updatedSections = newSections;
|
||||
updatedLabels = newLabels;
|
||||
} else if (numberOfLevelParts < currentSections.length) {
|
||||
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
||||
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedExpandedSections = expandedSections.filter(
|
||||
sectionId => updatedSections.some(section => section.sectionId === sectionId)
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_MODULE',
|
||||
payload: {
|
||||
updates: {
|
||||
sections: updatedSections,
|
||||
sectionLabels: updatedLabels,
|
||||
expandedSections: updatedExpandedSections
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const currentSections = sections;
|
||||
const currentLabels = sectionLabels;
|
||||
let updatedSections: SectionState[];
|
||||
let updatedLabels: any;
|
||||
if (
|
||||
(currentModule === "level" &&
|
||||
currentSections.length !== currentLabels.length) ||
|
||||
numberOfLevelParts !== currentSections.length
|
||||
) {
|
||||
const newSections = [...currentSections];
|
||||
const newLabels = [...currentLabels];
|
||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||
if (currentSections.length !== numberOfLevelParts)
|
||||
newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||
newLabels.push({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numberOfLevelParts]);
|
||||
|
||||
const sectionIds = sections.map((section) => section.sectionId)
|
||||
|
||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||
}, [dispatch]);
|
||||
|
||||
const toggleSection = (sectionId: number) => {
|
||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||
toast.error("Include at least one section!");
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'TOGGLE_SECTION', payload: { sectionId } });
|
||||
};
|
||||
|
||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings
|
||||
};
|
||||
|
||||
const Settings = ModuleSettings[currentModule];
|
||||
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
|
||||
|
||||
const updateLevelParts = (parts: number) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
}
|
||||
updatedSections = newSections;
|
||||
updatedLabels = newLabels;
|
||||
} else if (numberOfLevelParts < currentSections.length) {
|
||||
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
||||
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> : (
|
||||
<>
|
||||
{isResetModuleOpen && <ResetModule module={currentModule} isOpen={isResetModuleOpen} setIsOpen={setIsResetModuleOpen} setNumberOfLevelParts={setNumberOfLevelParts}/>}
|
||||
<div className="flex gap-4 w-full items-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES.map((x) => ({
|
||||
value: x,
|
||||
label: capitalize(x)
|
||||
}))}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values ? values.map(v => v.value as Difficulty) : [];
|
||||
updateModule({ difficulty: selectedDifficulties });
|
||||
}}
|
||||
value={
|
||||
difficulty
|
||||
? difficulty.map(d => ({
|
||||
value: d,
|
||||
label: capitalize(d)
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{(sectionLabels.length != 0 && currentModule !== "level") ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
|
||||
<div className="flex flex-row gap-8">
|
||||
{sectionLabels.map(({ id, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
sectionIds.includes(id)
|
||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||
: "bg-white border-mti-gray-platinum"
|
||||
)}
|
||||
onClick={() => toggleSection(id)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
||||
<Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||
<div className="h-6" />
|
||||
<Checkbox isChecked={isPrivate} onChange={(checked) => updateModule({ isPrivate: checked })}>
|
||||
Privacy (Only available for Assignments)
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Exam Label"
|
||||
name="label"
|
||||
onChange={(text) => updateModule({ examLabel: text })}
|
||||
roundness="xl"
|
||||
value={examLabel}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{currentModule === "listening" && <ListeningInstructions />}
|
||||
<Button
|
||||
onClick={() => setIsResetModuleOpen(true)}
|
||||
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
||||
className={`text-white self-end`}
|
||||
>
|
||||
Reset Module
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-8">
|
||||
<Settings />
|
||||
<div className="flex-grow max-w-[66%]">
|
||||
<SectionRenderer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
const updatedExpandedSections = expandedSections.filter((sectionId) =>
|
||||
updatedSections.some((section) => section.sectionId === sectionId)
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sections: updatedSections,
|
||||
sectionLabels: updatedLabels,
|
||||
expandedSections: updatedExpandedSections,
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numberOfLevelParts]);
|
||||
|
||||
const sectionIds = sections.map((section) => section.sectionId);
|
||||
|
||||
const updateModule = useCallback(
|
||||
(updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const toggleSection = (sectionId: number) => {
|
||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||
toast.error("Include at least one section!");
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
||||
};
|
||||
|
||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings,
|
||||
};
|
||||
|
||||
const Settings = ModuleSettings[currentModule];
|
||||
const showImport =
|
||||
importModule && ["reading", "listening", "level"].includes(currentModule);
|
||||
|
||||
const updateLevelParts = (parts: number) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showImport ? (
|
||||
<ImportOrStartFromScratch
|
||||
module={currentModule}
|
||||
setNumberOfLevelParts={updateLevelParts}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isResetModuleOpen && (
|
||||
<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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Timer
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) =>
|
||||
updateModule({
|
||||
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||
})
|
||||
}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Difficulty
|
||||
</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES.map((x) => ({
|
||||
value: x,
|
||||
label: capitalize(x),
|
||||
}))}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values
|
||||
? values.map((v) => v.value as Difficulty)
|
||||
: [];
|
||||
updateModule({ difficulty: selectedDifficulties });
|
||||
}}
|
||||
value={
|
||||
difficulty
|
||||
? difficulty.map((d) => ({
|
||||
value: d,
|
||||
label: capitalize(d),
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
||||
<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">
|
||||
{sectionLabels.map(({ id, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
sectionIds.includes(id)
|
||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||
: "bg-white border-mti-gray-platinum"
|
||||
)}
|
||||
onClick={() => toggleSection(id)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Number of Parts
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="Number of Parts"
|
||||
min={1}
|
||||
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
|
||||
value={numberOfLevelParts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||
<div className="h-6" />
|
||||
<Checkbox
|
||||
isChecked={isPrivate}
|
||||
onChange={(checked) => updateModule({ isPrivate: checked })}
|
||||
>
|
||||
Privacy (Only available for Assignments)
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Exam Label *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Exam Label"
|
||||
name="label"
|
||||
onChange={(text) => updateModule({ examLabel: text })}
|
||||
roundness="xl"
|
||||
value={examLabel}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{currentModule === "listening" && <ListeningInstructions />}
|
||||
<Button
|
||||
onClick={() => setIsResetModuleOpen(true)}
|
||||
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
||||
className={`text-white self-end`}
|
||||
>
|
||||
Reset Module
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-8 -2xl:flex-col">
|
||||
<Settings />
|
||||
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||
<SectionRenderer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamEditor;
|
||||
|
||||
@@ -24,7 +24,7 @@ const DroppableQuestionArea: React.FC<DroppableQuestionAreaProps> = ({ question,
|
||||
</div>
|
||||
<div
|
||||
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}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
|
||||
import {
|
||||
activeAssignmentFilter,
|
||||
futureAssignmentFilter,
|
||||
} from "@/utils/assignments";
|
||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
@@ -11,102 +14,124 @@ import Button from "../Low/Button";
|
||||
import ModuleBadge from "../ModuleBadge";
|
||||
|
||||
interface Props {
|
||||
assignment: Assignment
|
||||
user: User
|
||||
session?: Session
|
||||
startAssignment: (assignment: Assignment) => void
|
||||
resumeAssignment: (session: Session) => void
|
||||
assignment: Assignment;
|
||||
user: User;
|
||||
session?: Session;
|
||||
startAssignment: (assignment: Assignment) => void;
|
||||
resumeAssignment: (session: Session) => void;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) {
|
||||
const router = useRouter()
|
||||
export default function AssignmentCard({
|
||||
user,
|
||||
assignment,
|
||||
session,
|
||||
startAssignment,
|
||||
resumeAssignment,
|
||||
}: Props) {
|
||||
const hasBeenSubmitted = useMemo(
|
||||
() => assignment.results.map((r) => r.user).includes(user.id),
|
||||
[assignment.results, user.id]
|
||||
);
|
||||
|
||||
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"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",
|
||||
)}
|
||||
key={assignment.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||
<span className="flex justify-between gap-1 text-lg">
|
||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
.sort(sortByModuleName)
|
||||
.map((module) => (
|
||||
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
||||
))}
|
||||
</div>
|
||||
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
||||
<Button
|
||||
color="rose"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
disabled
|
||||
variant="outline">
|
||||
Not yet started
|
||||
</Button>
|
||||
)}
|
||||
{activeAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
||||
<>
|
||||
<div
|
||||
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">
|
||||
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
{!session && (
|
||||
<div
|
||||
data-tip="You have already started this assignment!"
|
||||
className={clsx(
|
||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||
!!session && "tooltip",
|
||||
)}>
|
||||
<Button
|
||||
className={clsx("w-full h-full !rounded-xl")}
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!!session && (
|
||||
<div
|
||||
className={clsx(
|
||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer"
|
||||
)}>
|
||||
<Button
|
||||
className={clsx("w-full h-full !rounded-xl")}
|
||||
onClick={() => resumeAssignment(session)}
|
||||
color="green"
|
||||
variant="outline">
|
||||
Resume
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasBeenSubmitted && (
|
||||
<Button
|
||||
color="green"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
disabled
|
||||
variant="outline">
|
||||
Submitted
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"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"
|
||||
)}
|
||||
key={assignment.id}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-mti-black/90 text-xl font-semibold">
|
||||
{assignment.name}
|
||||
</h3>
|
||||
<span className="flex justify-between gap-1 text-lg">
|
||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
.sort(sortByModuleName)
|
||||
.map((module) => (
|
||||
<ModuleBadge
|
||||
className="scale-110 w-full"
|
||||
key={module}
|
||||
module={module}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
||||
<Button
|
||||
color="rose"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
disabled
|
||||
variant="outline"
|
||||
>
|
||||
Not yet started
|
||||
</Button>
|
||||
)}
|
||||
{activeAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
||||
<>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
{!session && (
|
||||
<div
|
||||
data-tip="You have already started this assignment!"
|
||||
className={clsx(
|
||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||
!!session && "tooltip"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className={clsx("w-full h-full !rounded-xl")}
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!!session && (
|
||||
<div
|
||||
className={clsx(
|
||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className={clsx("w-full h-full !rounded-xl")}
|
||||
onClick={() => resumeAssignment(session)}
|
||||
color="green"
|
||||
variant="outline"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasBeenSubmitted && (
|
||||
<Button
|
||||
color="green"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
disabled
|
||||
variant="outline"
|
||||
>
|
||||
Submitted
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import {useListSearch} from "@/hooks/useListSearch";
|
||||
import usePagination from "@/hooks/usePagination";
|
||||
import { clsx } from "clsx";
|
||||
import {ReactNode} from "react";
|
||||
import Checkbox from "../Low/Checkbox";
|
||||
import Separator from "../Low/Separator";
|
||||
|
||||
interface Props<T> {
|
||||
list: T[];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import useEntities from "@/hooks/useEntities";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
@@ -6,66 +5,126 @@ import { useRouter } from "next/router";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import Navbar from "../Navbar";
|
||||
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 {
|
||||
user: User;
|
||||
entities?: EntityWithRoles[]
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
hideSidebar?: boolean
|
||||
bgColor?: string;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
user: User;
|
||||
entities?: EntityWithRoles[];
|
||||
children: React.ReactNode;
|
||||
refreshPage?: boolean;
|
||||
}
|
||||
|
||||
export default function Layout({
|
||||
user,
|
||||
children,
|
||||
className,
|
||||
bgColor = "bg-white",
|
||||
hideSidebar,
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
onFocusLayerMouseEnter
|
||||
user,
|
||||
entities,
|
||||
children,
|
||||
refreshPage,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { entities } = useEntities()
|
||||
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("");
|
||||
|
||||
return (
|
||||
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
||||
<ToastContainer />
|
||||
{!hideSidebar && user && (
|
||||
<Navbar
|
||||
path={router.pathname}
|
||||
user={user}
|
||||
navDisabled={navDisabled}
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
/>
|
||||
)}
|
||||
<div className={clsx("h-full w-full flex gap-2")}>
|
||||
{!hideSidebar && user && (
|
||||
<Sidebar
|
||||
path={router.pathname}
|
||||
navDisabled={navDisabled}
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
user={user}
|
||||
entities={entities}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
`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",
|
||||
hideSidebar ? "md:mx-8" : "md:mr-8",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
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();
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={LayoutContextValue}>
|
||||
<main
|
||||
className={clsx(
|
||||
"w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative"
|
||||
)}
|
||||
>
|
||||
<ToastContainer />
|
||||
{!hideSidebar && user && (
|
||||
<Navbar
|
||||
path={router.pathname}
|
||||
user={user}
|
||||
navDisabled={navDisabled}
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
/>
|
||||
)}
|
||||
<div className={clsx("h-full w-full flex gap-2")}>
|
||||
{!hideSidebar && user && (
|
||||
<Sidebar
|
||||
path={router.pathname}
|
||||
navDisabled={navDisabled}
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
user={user}
|
||||
entities={entities}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
`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",
|
||||
hideSidebar ? "md:mx-8" : "md:mr-8",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,109 +1,159 @@
|
||||
import { useListSearch } from "@/hooks/useListSearch"
|
||||
import { ColumnDef, flexRender, getCoreRowModel, 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"
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
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> {
|
||||
data: T[]
|
||||
columns: ColumnDef<any, any>[]
|
||||
searchFields: string[][]
|
||||
size?: number
|
||||
onDownload?: (rows: T[]) => void
|
||||
isDownloadLoading?: boolean
|
||||
searchPlaceholder?: string
|
||||
data: T[];
|
||||
columns: ColumnDef<any, any>[];
|
||||
searchFields: string[][];
|
||||
size?: number;
|
||||
onDownload?: (rows: T[]) => void;
|
||||
isDownloadLoading?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload, isDownloadLoading, searchPlaceholder }: Props<T>) {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: size,
|
||||
})
|
||||
export default function Table<T>({
|
||||
data,
|
||||
columns,
|
||||
searchFields,
|
||||
size = 16,
|
||||
onDownload,
|
||||
isDownloadLoading,
|
||||
searchPlaceholder,
|
||||
isLoading,
|
||||
}: Props<T>) {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: size,
|
||||
});
|
||||
|
||||
const { rows, renderSearch } = useListSearch<T>(searchFields, data, searchPlaceholder);
|
||||
const { rows, renderSearch } = useListSearch<T>(
|
||||
searchFields,
|
||||
data,
|
||||
searchPlaceholder
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
state: {
|
||||
pagination
|
||||
}
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<div className="w-full flex gap-2 items-end">
|
||||
{renderSearch()}
|
||||
{onDownload && (
|
||||
<Button isLoading={isDownloadLoading} className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
|
||||
Download
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<div className="w-full flex gap-2 items-end">
|
||||
{renderSearch()}
|
||||
{onDownload && (
|
||||
<Button
|
||||
isLoading={isDownloadLoading}
|
||||
className="w-full max-w-[200px] mb-1"
|
||||
variant="outline"
|
||||
onClick={() => onDownload(rows)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2 justify-between items-center">
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<span className="flex items-center gap-1">
|
||||
<div>Page</div>
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount().toLocaleString()}
|
||||
</strong>
|
||||
<div>| Total: {table.getRowCount().toLocaleString()}</div>
|
||||
</span>
|
||||
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex gap-2 justify-between items-center">
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<Button
|
||||
className="w-[200px] h-fit"
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
onClick={() => table.previousPage()}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<span className="flex items-center gap-1">
|
||||
<div>Page</div>
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount().toLocaleString()}
|
||||
</strong>
|
||||
<div>| Total: {table.getRowCount().toLocaleString()}</div>
|
||||
</span>
|
||||
<Button
|
||||
className="w-[200px]"
|
||||
disabled={!table.getCanNextPage()}
|
||||
onClick={() => table.nextPage()}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}>
|
||||
<div
|
||||
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: <BsArrowUp />,
|
||||
desc: <BsArrowDown />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2 w-full">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
className="py-4 px-4 text-left"
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
header.column.getCanSort() &&
|
||||
"cursor-pointer select-none",
|
||||
"flex items-center gap-2"
|
||||
)}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: <BsArrowUp />,
|
||||
desc: <BsArrowDown />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2 w-full">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading && (
|
||||
<div className="min-h-screen flex justify-center items-start">
|
||||
<span className="loading loading-infinity w-32" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
126
src/components/Low/AsyncSelect.tsx
Normal file
126
src/components/Low/AsyncSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,165 +9,216 @@ import useRecordStore from "@/stores/recordStore";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { mapBy } from "@/utils";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
|
||||
import useUsersSelect from "../../hooks/useUsersSelect";
|
||||
import AsyncSelect from "../Low/AsyncSelect";
|
||||
|
||||
type TimeFilter = "months" | "weeks" | "days";
|
||||
type Filter = TimeFilter | "assignments" | undefined;
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
entities: EntityWithRoles[]
|
||||
users: User[]
|
||||
filterState: {
|
||||
filter: Filter,
|
||||
setFilter: React.Dispatch<React.SetStateAction<Filter>>
|
||||
},
|
||||
assignments?: boolean;
|
||||
children?: ReactNode
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
isAdmin?: boolean;
|
||||
filterState: {
|
||||
filter: Filter;
|
||||
setFilter: React.Dispatch<React.SetStateAction<Filter>>;
|
||||
};
|
||||
assignments?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const defaultSelectableCorporate = {
|
||||
value: "",
|
||||
label: "All",
|
||||
value: "",
|
||||
label: "All",
|
||||
};
|
||||
|
||||
const RecordFilter: React.FC<Props> = ({
|
||||
user,
|
||||
entities,
|
||||
users,
|
||||
filterState,
|
||||
assignments = true,
|
||||
children
|
||||
user,
|
||||
entities,
|
||||
filterState,
|
||||
assignments = true,
|
||||
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) => [
|
||||
state.selectedUser,
|
||||
state.setSelectedUser
|
||||
]);
|
||||
const [, setStatsUserId] = useRecordStore((state) => [
|
||||
state.selectedUser,
|
||||
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"
|
||||
);
|
||||
|
||||
|
||||
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
|
||||
setFilter((prev) => (prev === value ? undefined : value));
|
||||
};
|
||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
||||
<div className="xl:w-3/4 flex gap-2">
|
||||
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
|
||||
setFilter((prev) => (prev === value ? undefined : value));
|
||||
};
|
||||
|
||||
<Select
|
||||
options={allowedViewEntities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(value) => setEntity(value?.value || undefined)}
|
||||
isClearable
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||
return (
|
||||
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
||||
<div className="xl:w-3/4 flex gap-2">
|
||||
{checkAccess(user, ["developer", "admin", "mastercorporate"]) &&
|
||||
!children && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
|
||||
<Select
|
||||
options={entityUsers.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(user.type === "corporate" || user.type === "teacher") && !children && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||
<Select
|
||||
options={allowedViewEntities.map((e) => ({
|
||||
value: e.id,
|
||||
label: e.label,
|
||||
}))}
|
||||
onChange={(value) => setEntity(value?.value || undefined)}
|
||||
isClearable
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
User
|
||||
</label>
|
||||
|
||||
<Select
|
||||
options={users
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex gap-4 w-full justify-center xl:justify-end">
|
||||
{assignments && (
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "assignments" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("assignments")}>
|
||||
Assignments
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "months" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("months")}>
|
||||
Last month
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "weeks" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("weeks")}>
|
||||
Last week
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "days" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("days")}>
|
||||
Last day
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<AsyncSelect
|
||||
isLoading={isLoading}
|
||||
loadOptions={loadOptions}
|
||||
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
||||
options={users}
|
||||
defaultValue={{
|
||||
value: user.id,
|
||||
label: `${user.name} - ${user.email}`,
|
||||
}}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(user.type === "corporate" || user.type === "teacher") &&
|
||||
!children && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
User
|
||||
</label>
|
||||
|
||||
<AsyncSelect
|
||||
isLoading={isLoading}
|
||||
loadOptions={loadOptions}
|
||||
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
||||
options={users}
|
||||
defaultValue={{
|
||||
value: user.id,
|
||||
label: `${user.name} - ${user.email}`,
|
||||
}}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex gap-4 w-full justify-center xl:justify-end">
|
||||
{assignments && (
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "assignments" && "!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
onClick={() => toggleFilter("assignments")}
|
||||
>
|
||||
Assignments
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "months" && "!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
onClick={() => toggleFilter("months")}
|
||||
>
|
||||
Last month
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "weeks" && "!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
onClick={() => toggleFilter("weeks")}
|
||||
>
|
||||
Last week
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
filter === "days" && "!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
onClick={() => toggleFilter("days")}
|
||||
>
|
||||
Last day
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordFilter;
|
||||
|
||||
60
src/components/Medium/UserProfileSkeleton.tsx
Normal file
60
src/components/Medium/UserProfileSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,18 @@ import clsx from "clsx";
|
||||
import { IconType } from "react-icons";
|
||||
import { MdSpaceDashboard } from "react-icons/md";
|
||||
import {
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsPencil,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
BsPeople,
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
BsPeople,
|
||||
} from "react-icons/bs";
|
||||
import { GoWorkflow } from "react-icons/go";
|
||||
import { CiDumbbell } from "react-icons/ci";
|
||||
import { RiLogoutBoxFill } from "react-icons/ri";
|
||||
import Link from "next/link";
|
||||
@@ -24,218 +24,478 @@ import { preventNavigation } from "@/utils/navigation.disabled";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { getTypesOfUser } from "@/utils/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { useAllowedEntities, useAllowedEntitiesSomePermissions } from "@/hooks/useEntityPermissions";
|
||||
import {
|
||||
useAllowedEntities,
|
||||
useAllowedEntitiesSomePermissions,
|
||||
} from "@/hooks/useEntityPermissions";
|
||||
import { useMemo } from "react";
|
||||
import { PermissionType } from "../interfaces/permissions";
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
user: User;
|
||||
entities?: EntityWithRoles[]
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
user: User;
|
||||
entities?: EntityWithRoles[];
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
badge?: number;
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out relative",
|
||||
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",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
{!!badge && badge > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
"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",
|
||||
isMinimized && "absolute right-0 top-0",
|
||||
)}>
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
const Nav = ({
|
||||
Icon,
|
||||
label,
|
||||
path,
|
||||
keyPath,
|
||||
disabled = false,
|
||||
isMinimized = false,
|
||||
badge,
|
||||
}: NavProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out relative",
|
||||
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",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]"
|
||||
)}
|
||||
>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
{!!badge && badge > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
"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",
|
||||
isMinimized && "absolute right-0 top-0"
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Sidebar({
|
||||
path,
|
||||
entities = [],
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
user,
|
||||
onFocusLayerMouseEnter,
|
||||
className
|
||||
path,
|
||||
entities = [],
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
user,
|
||||
onFocusLayerMouseEnter,
|
||||
className,
|
||||
}: 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 entitiesAllowPaymentRecord = useAllowedEntities(user, entities, "view_payment_record")
|
||||
const entitiesAllowStatistics = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_statistics"
|
||||
);
|
||||
const entitiesAllowPaymentRecord = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_payment_record"
|
||||
);
|
||||
|
||||
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [
|
||||
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level"
|
||||
])
|
||||
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
|
||||
user,
|
||||
entities,
|
||||
[
|
||||
"generate_reading",
|
||||
"generate_listening",
|
||||
"generate_writing",
|
||||
"generate_speaking",
|
||||
"generate_level",
|
||||
]
|
||||
);
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
if (!user || !user?.type) return sidebarPermissions;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"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",
|
||||
className,
|
||||
)}>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/dashboard" isMinimized={isMinimized} />
|
||||
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Practice" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"])) && entitiesAllowStatistics.length > 0 && (
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin", "mastercorporate", "corporate", "teacher", "student"], permissions) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPeople}
|
||||
label="Classrooms"
|
||||
path={path}
|
||||
keyPath="/classrooms"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"]) && entitiesAllowPaymentRecord.length > 0 && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
label="Tickets"
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "teacher", 'corporate', 'mastercorporate'])
|
||||
&& (entitiesAllowGeneration.length > 0 || isAdmin) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized />
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized />
|
||||
)}
|
||||
{entitiesAllowGeneration.length > 0 && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
const neededPermissions = permissions.reduce((acc, curr) => {
|
||||
if (
|
||||
["viewExams", "viewRecords", "viewTickets"].includes(curr as string)
|
||||
) {
|
||||
acc.push(curr);
|
||||
}
|
||||
return acc;
|
||||
}, [] as PermissionType[]);
|
||||
|
||||
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
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",
|
||||
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>}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => { } : logout}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
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 () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||
isMinimized ? "w-fit" : "-xl:w-20 w-1/6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewStats"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsGraphUp}
|
||||
label="Stats"
|
||||
path={path}
|
||||
keyPath="/stats"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewClassrooms"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPeople}
|
||||
label="Classrooms"
|
||||
path={path}
|
||||
keyPath="/classrooms"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClockHistory}
|
||||
label="Record"
|
||||
path={path}
|
||||
keyPath="/record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={CiDumbbell}
|
||||
label="Training"
|
||||
path={path}
|
||||
keyPath="/training"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewPaymentRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewSettings"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewTickets"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
label="Tickets"
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewGeneration"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewApprovalWorkflows"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={GoWorkflow}
|
||||
label="Approval Workflows"
|
||||
path={path}
|
||||
keyPath="/approval-workflows"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={MdSpaceDashboard}
|
||||
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
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClockHistory}
|
||||
label="Record"
|
||||
path={path}
|
||||
keyPath="/record"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={CiDumbbell}
|
||||
label="Training"
|
||||
path={path}
|
||||
keyPath="/training"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewSettings"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewGeneration"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
{sidebarPermissions["viewApprovalWorkflows"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={GoWorkflow}
|
||||
label="Approval Workflows"
|
||||
path={path}
|
||||
keyPath="/approval-workflows"
|
||||
isMinimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
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",
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
|
||||
)}
|
||||
>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && (
|
||||
<span className="-xl:hidden text-lg font-medium">Log Out</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && (
|
||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function QuestionSolutionArea({
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
? "border-mti-gray-davy"
|
||||
: userSolution.option.toString() === question.solution.toString()
|
||||
|
||||
204
src/demo/approval_workflows.json
Normal file
204
src/demo/approval_workflows.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,310 +1,452 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {useMemo, useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Module } from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import {
|
||||
BsArrowRepeat,
|
||||
BsBook,
|
||||
BsCheck,
|
||||
BsCheckCircle,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
BsXCircle,
|
||||
} from "react-icons/bs";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import { Variant } from "@/interfaces/exam";
|
||||
import useSessions, { Session } from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import moment from "moment";
|
||||
import useStats from "../hooks/useStats";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (
|
||||
modules: Module[],
|
||||
avoidRepeated: boolean,
|
||||
variant: Variant
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function Selection({user, page, onStart}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
export default function Selection({ user, page, onStart }: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
|
||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||
const {
|
||||
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 modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||
};
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) =>
|
||||
prev.includes(module) ? modules : [...modules, module]
|
||||
);
|
||||
};
|
||||
|
||||
const isCompleteExam = useMemo(() =>
|
||||
["reading", "listening", "writing", "speaking"].every(m => selectedModules.includes(m as Module)), [selectedModules]
|
||||
)
|
||||
const isCompleteExam = useMemo(
|
||||
() =>
|
||||
["reading", "listening", "writing", "speaking"].every((m) =>
|
||||
selectedModules.includes(m as Module)
|
||||
),
|
||||
[selectedModules]
|
||||
);
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
dispatch({type: "SET_SESSION", payload: { session }})
|
||||
};
|
||||
const loadSession = async (session: Session) => {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
tooltip: "The amount of reading exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
tooltip: "The amount of listening exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
tooltip: "The amount of writing exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
tooltip: "The amount of speaking exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
tooltip: "The amount of level exams performed.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: (
|
||||
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Reading",
|
||||
value: reading || 0,
|
||||
tooltip: "The amount of reading exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Listening",
|
||||
value: listening || 0,
|
||||
tooltip: "The amount of listening exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Writing",
|
||||
value: writing || 0,
|
||||
tooltip: "The amount of writing exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Speaking",
|
||||
value: speaking || 0,
|
||||
tooltip: "The amount of speaking exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Level",
|
||||
value: level || 0,
|
||||
tooltip: "The amount of level exams performed.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
||||
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
||||
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
|
||||
designed to make learning English both enjoyable and effective. Whether you'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" && (
|
||||
<>
|
||||
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
|
||||
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
|
||||
your abilities. Whether you're a beginner or a seasoned learner, 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's a testament to your dedication and our commitment to empowering you with the English language.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect,
|
||||
and our exercises are the key to unlocking your full potential.
|
||||
Dive into a world of interactive and engaging exercises that
|
||||
cater to diverse learning styles. From grammar drills that build
|
||||
a strong foundation to vocabulary challenges that broaden your
|
||||
lexicon, our exercises are carefully designed to make learning
|
||||
English both enjoyable and effective. Whether you'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" && (
|
||||
<>
|
||||
Welcome to the heart of success on your English language
|
||||
journey! Our exams are crafted with precision to assess and
|
||||
enhance your language skills. Each test is a passport to your
|
||||
linguistic prowess, designed to challenge and elevate your
|
||||
abilities. Whether you're a beginner or a seasoned learner,
|
||||
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's
|
||||
a testament to your dedication and our commitment to empowering
|
||||
you with the English language.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
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">
|
||||
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<span className="text-mti-black text-lg font-bold">
|
||||
Unfinished Sessions
|
||||
</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
|
||||
{sessions.map((session) => (
|
||||
<SessionCard
|
||||
session={session}
|
||||
key={session.sessionId}
|
||||
reload={reload}
|
||||
loadSession={loadSession}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
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",
|
||||
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">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("reading")) && (
|
||||
<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" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
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",
|
||||
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">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("listening")) && (
|
||||
<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" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
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",
|
||||
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">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("writing")) && (
|
||||
<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" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
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",
|
||||
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">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking")) && (
|
||||
<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" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||
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",
|
||||
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">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Level:</span>
|
||||
<p className="text-left text-xs">You'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" />
|
||||
)}
|
||||
{(selectedModules.includes("level")) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
||||
Avoid Repeated Questions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
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"))}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
</div>
|
||||
<div 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
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<Button
|
||||
color="green"
|
||||
variant={isCompleteExam ? "solid" : "outline"}
|
||||
onClick={() => isCompleteExam ? setSelectedModules([]) : setSelectedModules(["reading", "listening", "writing", "speaking"])}
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
>
|
||||
Complete Exam
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
selectedModules.sort(sortByModuleName),
|
||||
avoidRepeatedExams,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0}>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={
|
||||
!selectedModules.includes("level")
|
||||
? () => toggleModule("reading")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
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">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and
|
||||
improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") &&
|
||||
!selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{selectedModules.includes("reading") && (
|
||||
<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" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!selectedModules.includes("level")
|
||||
? () => toggleModule("listening")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
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">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your
|
||||
ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") &&
|
||||
!selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{selectedModules.includes("listening") && (
|
||||
<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" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!selectedModules.includes("level")
|
||||
? () => toggleModule("writing")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
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">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple
|
||||
paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") &&
|
||||
!selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{selectedModules.includes("writing") && (
|
||||
<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" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!selectedModules.includes("level")
|
||||
? () => toggleModule("speaking")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
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">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation
|
||||
exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") &&
|
||||
!selectedModules.includes("level") && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{selectedModules.includes("speaking") && (
|
||||
<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" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
selectedModules.length === 0 || selectedModules.includes("level")
|
||||
? () => toggleModule("level")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
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">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Level:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'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" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") &&
|
||||
selectedModules.length > 0 && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
|
||||
>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light "
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span
|
||||
className="tooltip"
|
||||
data-tip="If possible, the platform will choose exams not yet done."
|
||||
>
|
||||
Avoid Repeated Questions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
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"))
|
||||
}
|
||||
>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light "
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<Button
|
||||
color="green"
|
||||
variant={isCompleteExam ? "solid" : "outline"}
|
||||
onClick={() =>
|
||||
isCompleteExam
|
||||
? setSelectedModules([])
|
||||
: setSelectedModules([
|
||||
"reading",
|
||||
"listening",
|
||||
"writing",
|
||||
"speaking",
|
||||
])
|
||||
}
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
>
|
||||
Complete Exam
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
selectedModules.sort(sortByModuleName),
|
||||
avoidRepeatedExams,
|
||||
variant
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0}
|
||||
>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/hooks/useApprovalWorkflow.tsx
Normal file
24
src/hooks/useApprovalWorkflow.tsx
Normal 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 };
|
||||
}
|
||||
24
src/hooks/useApprovalWorkflows.tsx
Normal file
24
src/hooks/useApprovalWorkflows.tsx
Normal 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 };
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Discount } from "@/interfaces/paypal";
|
||||
import { Code, Group, User } from "@/interfaces/user";
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
const getData = useCallback(() => {
|
||||
if (shouldNot) return;
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<EntityWithRoles[]>("/api/entities?showRoles=true")
|
||||
.then((response) => setEntities(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
}, [shouldNot]);
|
||||
|
||||
useEffect(getData, []);
|
||||
useEffect(getData, [getData])
|
||||
|
||||
return { entities, isLoading, isError, reload: getData };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity";
|
||||
import { Discount } from "@/interfaces/paypal";
|
||||
import { Code, Group, Type, User } from "@/interfaces/user";
|
||||
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -12,7 +11,9 @@ export default function useEntitiesUsers(type?: Type) {
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<WithLabeledEntities<User>[]>(`/api/entities/users${type ? "?type=" + type : ""}`)
|
||||
.get<WithLabeledEntities<User>[]>(
|
||||
`/api/entities/users${type ? "?type=" + type : ""}`
|
||||
)
|
||||
.then((response) => setUsers(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
@@ -1,95 +1,114 @@
|
||||
import { UserSolution } from '@/interfaces/exam';
|
||||
import useExamStore from '@/stores/exam';
|
||||
import { StateFlags } from '@/stores/exam/types';
|
||||
import axios from 'axios';
|
||||
import { SetStateAction, useEffect, useRef } from 'react';
|
||||
import { UserSolution } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import axios from "axios";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type UseEvaluationPolling = (props: {
|
||||
pendingExercises: string[],
|
||||
setPendingExercises: React.Dispatch<SetStateAction<string[]>>,
|
||||
}) => void;
|
||||
const useEvaluationPolling = (sessionIds: string[], mode: "exam" | "records", userId: string) => {
|
||||
const { setUserSolutions, userSolutions } = useExamStore();
|
||||
const pollingTimeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const router = useRouter();
|
||||
|
||||
const useEvaluationPolling: UseEvaluationPolling = ({
|
||||
pendingExercises,
|
||||
setPendingExercises,
|
||||
}) => {
|
||||
const {
|
||||
flags, sessionId, user,
|
||||
userSolutions, evaluated,
|
||||
setEvaluated, setFlags
|
||||
} = useExamStore();
|
||||
const poll = async (sessionId: string) => {
|
||||
try {
|
||||
const { data: statusData } = await axios.get('/api/evaluate/status', {
|
||||
params: { op: 'pending', userId, sessionId }
|
||||
});
|
||||
|
||||
const pollingTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
if (!statusData.hasPendingEvaluation) {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingTimeoutRef.current) {
|
||||
clearTimeout(pollingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
let solutionsOrStats = userSolutions;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flags.pendingEvaluation || pendingExercises.length === 0) {
|
||||
|
||||
if (pollingTimeoutRef.current) {
|
||||
clearTimeout(pollingTimeoutRef.current);
|
||||
}
|
||||
return;
|
||||
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,
|
||||
});
|
||||
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/evaluate/status', {
|
||||
params: {
|
||||
sessionId,
|
||||
userId: user,
|
||||
exerciseIds: pendingExercises.join(',')
|
||||
}
|
||||
});
|
||||
await axios.post('/api/stats/disabled', {
|
||||
sessionId,
|
||||
userId,
|
||||
solutions: completedSolutions,
|
||||
});
|
||||
|
||||
if (data.finishedExerciseIds.length > 0) {
|
||||
const remainingExercises = pendingExercises.filter(
|
||||
id => !data.finishedExerciseIds.includes(id)
|
||||
);
|
||||
const timeout = pollingTimeoutsRef.current.get(sessionId);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
pollingTimeoutsRef.current.delete(sessionId);
|
||||
|
||||
setPendingExercises(remainingExercises);
|
||||
if (mode === "exam") {
|
||||
const updatedSolutions = userSolutions.map(solution => {
|
||||
const completed = completedSolutions.find(
|
||||
(c: UserSolution) => c.exercise === solution.exercise
|
||||
);
|
||||
return completed || solution;
|
||||
});
|
||||
|
||||
if (remainingExercises.length === 0) {
|
||||
const evaluatedData = await axios.post('/api/evaluate/fetchSolutions', {
|
||||
sessionId,
|
||||
userId: user,
|
||||
userSolutions
|
||||
});
|
||||
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)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const newEvaluations = evaluatedData.data.filter(
|
||||
(newEval: UserSolution) =>
|
||||
!evaluated.some(existingEval => existingEval.exercise === newEval.exercise)
|
||||
);
|
||||
useEffect(() => {
|
||||
if (mode === "exam") {
|
||||
const hasDisabledSolutions = userSolutions.some(s => s.isDisabled);
|
||||
|
||||
setEvaluated([...evaluated, ...newEvaluations]);
|
||||
setFlags({ pendingEvaluation: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
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]);
|
||||
|
||||
if (pendingExercises.length > 0) {
|
||||
pollingTimeoutRef.current = setTimeout(pollStatus, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Evaluation polling error:', error);
|
||||
pollingTimeoutRef.current = setTimeout(pollStatus, 5000);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (mode === "records" && sessionIds.length > 0) {
|
||||
sessionIds.forEach(sessionId => {
|
||||
poll(sessionId);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, sessionIds]);
|
||||
|
||||
pollStatus();
|
||||
useEffect(() => {
|
||||
const timeouts = pollingTimeoutsRef.current;
|
||||
return () => {
|
||||
timeouts.forEach((timeout) => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
timeouts.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
if (pollingTimeoutRef.current) {
|
||||
clearTimeout(pollingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
});
|
||||
return {
|
||||
isPolling: pollingTimeoutsRef.current.size > 0
|
||||
};
|
||||
};
|
||||
|
||||
export default useEvaluationPolling;
|
||||
|
||||
@@ -3,13 +3,13 @@ import { useEffect, useState } from "react";
|
||||
|
||||
const endpoints: Record<string, string> = {
|
||||
stats: "/api/stats",
|
||||
training: "/api/training"
|
||||
training: "/api/training",
|
||||
};
|
||||
|
||||
export default function useFilterRecordsByUser<T extends any[]>(
|
||||
id?: string,
|
||||
shouldNotQuery?: boolean,
|
||||
recordType: string = 'stats'
|
||||
recordType: string = "stats"
|
||||
) {
|
||||
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;
|
||||
// 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 = () => {
|
||||
if (shouldNotQuery) return;
|
||||
@@ -31,7 +31,7 @@ export default function useFilterRecordsByUser<T extends any[]>(
|
||||
.get<T>(endpoint)
|
||||
.then((response) => {
|
||||
// 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))
|
||||
.finally(() => setIsLoading(false));
|
||||
@@ -42,10 +42,10 @@ export default function useFilterRecordsByUser<T extends any[]>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, shouldNotQuery, recordType, endpoint]);
|
||||
|
||||
return {
|
||||
data,
|
||||
reload: getData,
|
||||
isLoading,
|
||||
isError
|
||||
return {
|
||||
data,
|
||||
reload: getData,
|
||||
isLoading,
|
||||
isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ export default function usePermissions(user: string) {
|
||||
.get<Permission[]>(`/api/permissions`)
|
||||
.then((response) => {
|
||||
const permissionTypes = response.data
|
||||
.filter((x) => !x.users.includes(user))
|
||||
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
|
||||
.reduce((acc, curr) => curr.users.includes(user)? acc : [...acc, curr.type], [] as PermissionType[]);
|
||||
setPermissions(permissionTypes);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
|
||||
42
src/hooks/useStats.tsx
Normal file
42
src/hooks/useStats.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
import React from "react";
|
||||
import useTickets from "./useTickets";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
const useTicketsListener = (userId?: string) => {
|
||||
const { tickets, reload } = useTickets();
|
||||
const useTicketsListener = (userId?: string, canFetch?: boolean) => {
|
||||
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(() => {
|
||||
reload();
|
||||
getData();
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [reload]);
|
||||
}, [assignedTickets, canFetch, getData]);
|
||||
|
||||
if (userId) {
|
||||
const assignedTickets = tickets.filter(
|
||||
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
|
||||
);
|
||||
|
||||
return {
|
||||
assignedTickets,
|
||||
totalAssignedTickets: assignedTickets.length,
|
||||
|
||||
27
src/hooks/useUserData.tsx
Normal file
27
src/hooks/useUserData.tsx
Normal 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 };
|
||||
}
|
||||
99
src/hooks/useUsersSelect.tsx
Normal file
99
src/hooks/useUsersSelect.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
71
src/interfaces/approval.workflow.ts
Normal file
71
src/interfaces/approval.workflow.ts
Normal 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",
|
||||
};
|
||||
@@ -1,4 +1,11 @@
|
||||
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 {
|
||||
min: number;
|
||||
|
||||
@@ -37,3 +37,5 @@ export interface Assignment {
|
||||
}
|
||||
|
||||
export type AssignmentWithCorporateId = Assignment & { corporateId: string };
|
||||
|
||||
export type AssignmentWithHasResults = Assignment & { hasResults: boolean };
|
||||
|
||||
@@ -170,4 +170,24 @@ export interface Code {
|
||||
export type 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;
|
||||
|
||||
@@ -5,192 +5,329 @@ import Separator from "@/components/Low/Separator";
|
||||
import { Grading, Step } from "@/interfaces";
|
||||
import { Entity } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading";
|
||||
import { mapBy } from "@/utils";
|
||||
import {
|
||||
CEFR_STEPS,
|
||||
GENERAL_STEPS,
|
||||
IELTS_STEPS,
|
||||
TOFEL_STEPS,
|
||||
} from "@/resources/grading";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { Divider } from "primereact/divider";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BsPlusCircle, BsTrash } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const areStepsOverlapped = (steps: Step[]) => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (i === 0) continue;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (i === 0) continue;
|
||||
|
||||
const step = steps[i];
|
||||
const previous = steps[i - 1];
|
||||
const step = steps[i];
|
||||
const previous = steps[i - 1];
|
||||
|
||||
if (previous.max >= step.min) return true;
|
||||
}
|
||||
if (previous.max >= step.min) return true;
|
||||
}
|
||||
|
||||
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 {
|
||||
user: User;
|
||||
entitiesGrading: Grading[];
|
||||
entities: Entity[]
|
||||
mutate: () => void
|
||||
user: User;
|
||||
entitiesGrading: Grading[];
|
||||
entities: Entity[];
|
||||
mutate: () => void;
|
||||
}
|
||||
|
||||
export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) {
|
||||
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined)
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [steps, setSteps] = useState<Step[]>([]);
|
||||
const [otherEntities, setOtherEntities] = useState<string[]>([])
|
||||
export default function CorporateGradingSystem({
|
||||
user,
|
||||
entitiesGrading = [],
|
||||
entities = [],
|
||||
mutate,
|
||||
}: Props) {
|
||||
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [steps, setSteps] = useState<Step[]>([]);
|
||||
const [otherEntities, setOtherEntities] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps
|
||||
setSteps(entitySteps || [])
|
||||
}
|
||||
}, [entitiesGrading, entity])
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
const entitySteps = entitiesGrading.find(
|
||||
(e) => e.entity === entity
|
||||
)!.steps;
|
||||
setSteps(entitySteps || []);
|
||||
}
|
||||
}, [entitiesGrading, entity]);
|
||||
|
||||
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 (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
||||
if (
|
||||
steps.reduce((acc, curr) => {
|
||||
return acc - (curr.max - curr.min + 1);
|
||||
}, 100) > 0
|
||||
)
|
||||
return toast.error("There seems to be an open interval in your steps.");
|
||||
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 (areStepsOverlapped(steps))
|
||||
return toast.error("There seems to be an overlap in one of your steps.");
|
||||
if (
|
||||
steps.reduce((acc, curr) => {
|
||||
return acc - (curr.max - curr.min + 1);
|
||||
}, 100) > 0
|
||||
)
|
||||
return toast.error("There seems to be an open interval in your steps.");
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/grading", { user: user.id, entity, steps })
|
||||
.then(() => toast.success("Your grading system has been saved!"))
|
||||
.then(mutate)
|
||||
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/grading", { user: user.id, entity, steps })
|
||||
.then(() => toast.success("Your grading system has been saved!"))
|
||||
.then(mutate)
|
||||
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
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 (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
||||
if (
|
||||
steps.reduce((acc, curr) => {
|
||||
return acc - (curr.max - curr.min + 1);
|
||||
}, 100) > 0
|
||||
)
|
||||
return toast.error("There seems to be an open interval in your steps.");
|
||||
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 (areStepsOverlapped(steps))
|
||||
return toast.error("There seems to be an overlap in one of your steps.");
|
||||
if (
|
||||
steps.reduce((acc, curr) => {
|
||||
return acc - (curr.max - curr.min + 1);
|
||||
}, 100) > 0
|
||||
)
|
||||
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);
|
||||
axios
|
||||
.post("/api/grading/multiple", { user: user.id, entities: otherEntities, steps })
|
||||
.then(() => toast.success("Your grading system has been saved!"))
|
||||
.then(mutate)
|
||||
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/grading/multiple", {
|
||||
user: user.id,
|
||||
entities: otherEntities,
|
||||
steps,
|
||||
})
|
||||
.then(() => toast.success("Your grading system has been saved!"))
|
||||
.then(mutate)
|
||||
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Grading System</label>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
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),
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
{entities.length > 1 && (
|
||||
<>
|
||||
<Separator />
|
||||
<label className="font-normal text-base text-mti-gray-dim">Apply this grading system to other entities</label>
|
||||
<Select
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))}
|
||||
isMulti
|
||||
/>
|
||||
<Button onClick={applyToOtherEntities} isLoading={isLoading} disabled={isLoading || otherEntities.length === 0} variant="outline">
|
||||
Apply to {otherEntities.length} other entities
|
||||
</Button>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Grading System
|
||||
</label>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{
|
||||
value: (entities || [])[0]?.id,
|
||||
label: (entities || [])[0]?.label,
|
||||
}}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
||||
CEFR
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSteps(GENERAL_STEPS)}>
|
||||
General English
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSteps(IELTS_STEPS)}>
|
||||
IELTS
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSteps(TOFEL_STEPS)}>
|
||||
TOFEL iBT
|
||||
</Button>
|
||||
</div>
|
||||
{entities.length > 1 && (
|
||||
<>
|
||||
<Separator />
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Apply this grading system to other entities
|
||||
</label>
|
||||
<Select
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) =>
|
||||
!e
|
||||
? setOtherEntities([])
|
||||
: setOtherEntities(e.map((o) => o.value!))
|
||||
}
|
||||
isMulti
|
||||
/>
|
||||
<Button
|
||||
onClick={applyToOtherEntities}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading || otherEntities.length === 0}
|
||||
variant="outline"
|
||||
>
|
||||
Apply to {otherEntities.length} other entities
|
||||
</Button>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid grid-cols-3 gap-4 w-full" key={step.min}>
|
||||
<Input
|
||||
label="Min. Percentage"
|
||||
value={step.min}
|
||||
type="number"
|
||||
disabled={index === 0 || isLoading}
|
||||
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x)))}
|
||||
name="min"
|
||||
/>
|
||||
<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>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Preset Systems
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
||||
CEFR
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSteps(GENERAL_STEPS)}>
|
||||
General English
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSteps(IELTS_STEPS)}>
|
||||
IELTS
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSteps(TOFEL_STEPS)}>
|
||||
TOFEL iBT
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{steps.map((step, index) => (
|
||||
<GradingRowMemo
|
||||
key={index}
|
||||
min={step.min}
|
||||
max={step.max}
|
||||
label={step.label}
|
||||
index={index}
|
||||
isLoading={isLoading}
|
||||
isLast={index === steps.length - 1}
|
||||
setSteps={setSteps}
|
||||
addRow={addRow}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button onClick={saveGradingSystem} isLoading={isLoading} disabled={isLoading} className="mt-8">
|
||||
Save Grading System
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
<Button
|
||||
onClick={saveGradingSystem}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
className="mt-8"
|
||||
>
|
||||
Save Grading System
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import clsx from "clsx";
|
||||
import { capitalize } from "lodash";
|
||||
import moment from "moment";
|
||||
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 { countries, TCountries } from "countries-list";
|
||||
import countryCodes from "country-codes-list";
|
||||
@@ -24,426 +29,597 @@ import { WithLabeledEntities } from "@/interfaces/entity";
|
||||
import Table from "@/components/High/Table";
|
||||
import useEntities from "@/hooks/useEntities";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import { findAllowedEntities } from "@/utils/permissions";
|
||||
|
||||
const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
|
||||
const searchFields = [["name"], ["email"], ["entities", ""]];
|
||||
|
||||
export default function UserList({
|
||||
user,
|
||||
filters = [],
|
||||
type,
|
||||
renderHeader,
|
||||
user,
|
||||
filters = [],
|
||||
type,
|
||||
renderHeader,
|
||||
}: {
|
||||
user: User;
|
||||
filters?: ((user: User) => boolean)[];
|
||||
type?: Type;
|
||||
renderHeader?: (total: number) => JSX.Element;
|
||||
user: User;
|
||||
filters?: ((user: User) => boolean)[];
|
||||
type?: Type;
|
||||
renderHeader?: (total: number) => JSX.Element;
|
||||
}) {
|
||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showDemographicInformation, setShowDemographicInformation] =
|
||||
useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
|
||||
const { users, reload } = useEntitiesUsers(type)
|
||||
const { entities } = useEntities()
|
||||
const { users, isLoading, reload } = useEntitiesUsers(type);
|
||||
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 entitiesEditStudents = useAllowedEntities(user, entities, "edit_students")
|
||||
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students")
|
||||
const entitiesViewStudents = useAllowedEntities(
|
||||
user,
|
||||
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 entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers")
|
||||
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers")
|
||||
const entitiesViewTeachers = useAllowedEntities(
|
||||
user,
|
||||
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 entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates")
|
||||
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates")
|
||||
const entitiesViewCorporates = useAllowedEntities(
|
||||
user,
|
||||
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 entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates")
|
||||
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates")
|
||||
const entitiesViewMasterCorporates = useAllowedEntities(
|
||||
user,
|
||||
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 router = useRouter();
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
const today = moment(new Date());
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
const today = moment(new Date());
|
||||
|
||||
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(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
|
||||
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
|
||||
};
|
||||
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(2, "weeks").isAfter(momentDate))
|
||||
return "!text-mti-rose-light";
|
||||
if (today.add(1, "months").isAfter(momentDate))
|
||||
return "!text-mti-orange-light";
|
||||
};
|
||||
|
||||
const allowedUsers = useMemo(() => users.filter((u) => {
|
||||
if (isAdmin) return true
|
||||
if (u.id === user?.id) return false
|
||||
const allowedUsers = useMemo(
|
||||
() =>
|
||||
users.filter((u) => {
|
||||
if (isAdmin) return true;
|
||||
if (u.id === user?.id) return false;
|
||||
|
||||
switch (u.type) {
|
||||
case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id))
|
||||
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])
|
||||
switch (u.type) {
|
||||
case "student":
|
||||
return mapBy(u.entities || [], "id").some((id) =>
|
||||
mapBy(entitiesViewStudents, "id").includes(id)
|
||||
);
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
const displayUsers = useMemo(() =>
|
||||
filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers,
|
||||
[filters, allowedUsers])
|
||||
const displayUsers = useMemo(
|
||||
() =>
|
||||
filters.length > 0
|
||||
? filters.reduce((d, f) => d.filter(f), allowedUsers)
|
||||
: allowedUsers,
|
||||
[filters, allowedUsers]
|
||||
);
|
||||
|
||||
const deleteAccount = (user: User) => {
|
||||
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
||||
const deleteAccount = (user: User) => {
|
||||
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`))
|
||||
return;
|
||||
|
||||
axios
|
||||
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
|
||||
.then(() => {
|
||||
toast.success("User deleted successfully!");
|
||||
reload()
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong!", { toastId: "delete-error" });
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
axios
|
||||
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
|
||||
.then(() => {
|
||||
toast.success("User deleted successfully!");
|
||||
reload();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong!", { toastId: "delete-error" });
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const verifyAccount = (user: User) => {
|
||||
axios
|
||||
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||
...user,
|
||||
isVerified: true,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("User verified successfully!");
|
||||
reload();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||
});
|
||||
};
|
||||
const verifyAccount = (user: User) => {
|
||||
axios
|
||||
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||
...user,
|
||||
isVerified: true,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("User verified successfully!");
|
||||
reload();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDisableAccount = (user: User) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name
|
||||
}'s account? This change is usually related to their payment state.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const toggleDisableAccount = (user: User) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to ${
|
||||
user.status === "disabled" ? "enable" : "disable"
|
||||
} ${
|
||||
user.name
|
||||
}'s account? This change is usually related to their payment state.`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
axios
|
||||
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||
...user,
|
||||
status: user.status === "disabled" ? "active" : "disabled",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`);
|
||||
reload();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||
});
|
||||
};
|
||||
axios
|
||||
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||
...user,
|
||||
status: user.status === "disabled" ? "active" : "disabled",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`User ${
|
||||
user.status === "disabled" ? "enabled" : "disabled"
|
||||
} successfully!`
|
||||
);
|
||||
reload();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||
});
|
||||
};
|
||||
|
||||
const getEditPermission = (type: Type) => {
|
||||
if (type === "student") return entitiesEditStudents
|
||||
if (type === "teacher") return entitiesEditTeachers
|
||||
if (type === "corporate") return entitiesEditCorporates
|
||||
if (type === "mastercorporate") return entitiesEditMasterCorporates
|
||||
const getEditPermission = (type: Type) => {
|
||||
if (type === "student") return entitiesEditStudents;
|
||||
if (type === "teacher") return entitiesEditTeachers;
|
||||
if (type === "corporate") return entitiesEditCorporates;
|
||||
if (type === "mastercorporate") return entitiesEditMasterCorporates;
|
||||
|
||||
return []
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const getDeletePermission = (type: Type) => {
|
||||
if (type === "student") return entitiesDeleteStudents
|
||||
if (type === "teacher") return entitiesDeleteTeachers
|
||||
if (type === "corporate") return entitiesDeleteCorporates
|
||||
if (type === "mastercorporate") return entitiesDeleteMasterCorporates
|
||||
const getDeletePermission = (type: Type) => {
|
||||
if (type === "student") return entitiesDeleteStudents;
|
||||
if (type === "teacher") return entitiesDeleteTeachers;
|
||||
if (type === "corporate") return entitiesDeleteCorporates;
|
||||
if (type === "mastercorporate") return entitiesDeleteMasterCorporates;
|
||||
|
||||
return []
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const canEditUser = (u: User) =>
|
||||
isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id))
|
||||
const canEditUser = (u: User) =>
|
||||
isAdmin ||
|
||||
u.entities.some((e) =>
|
||||
mapBy(getEditPermission(u.type), "id").includes(e.id)
|
||||
);
|
||||
|
||||
const canDeleteUser = (u: User) =>
|
||||
isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id))
|
||||
const canDeleteUser = (u: User) =>
|
||||
isAdmin ||
|
||||
u.entities.some((e) =>
|
||||
mapBy(getDeletePermission(u.type), "id").includes(e.id)
|
||||
);
|
||||
|
||||
const actionColumn = ({ row }: { row: { original: User } }) => {
|
||||
const canEdit = canEditUser(row.original)
|
||||
const canDelete = canDeleteUser(row.original)
|
||||
const actionColumn = ({ row }: { row: { original: User } }) => {
|
||||
const canEdit = canEditUser(row.original);
|
||||
const canDelete = canDeleteUser(row.original);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{!row.original.isVerified && canEdit && (
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div
|
||||
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"}
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => toggleDisableAccount(row.original)}>
|
||||
{row.original.status === "disabled" ? (
|
||||
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
) : (
|
||||
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canDelete && (
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{!row.original.isVerified && canEdit && (
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div
|
||||
data-tip={
|
||||
row.original.status === "disabled"
|
||||
? "Enable User"
|
||||
: "Disable User"
|
||||
}
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => toggleDisableAccount(row.original)}
|
||||
>
|
||||
{row.original.status === "disabled" ? (
|
||||
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
) : (
|
||||
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canDelete && (
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const demographicColumns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: ({ row, getValue }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
canEditUser(row.original) &&
|
||||
"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
|
||||
}>
|
||||
{getValue()}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.country", {
|
||||
header: "Country",
|
||||
cell: (info) =>
|
||||
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})`
|
||||
: "N/A",
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.phone", {
|
||||
header: "Phone",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
enableSorting: true,
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
(x) =>
|
||||
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
|
||||
{
|
||||
id: "employment",
|
||||
header: "Employment",
|
||||
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
|
||||
enableSorting: true,
|
||||
},
|
||||
),
|
||||
columnHelper.accessor("lastLogin", {
|
||||
header: "Last Login",
|
||||
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.gender", {
|
||||
header: "Gender",
|
||||
cell: (info) => capitalize(info.getValue()) || "N/A",
|
||||
enableSorting: true,
|
||||
}),
|
||||
{
|
||||
header: (
|
||||
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
|
||||
Switch
|
||||
</span>
|
||||
),
|
||||
id: "actions",
|
||||
cell: actionColumn,
|
||||
sortable: false
|
||||
},
|
||||
];
|
||||
const demographicColumns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: ({ row, getValue }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
canEditUser(row.original) &&
|
||||
"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
|
||||
}
|
||||
>
|
||||
{getValue()}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.country", {
|
||||
header: "Country",
|
||||
cell: (info) =>
|
||||
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
|
||||
})`
|
||||
: "N/A",
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.phone", {
|
||||
header: "Phone",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
enableSorting: true,
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
(x) =>
|
||||
x.type === "corporate" || x.type === "mastercorporate"
|
||||
? x.demographicInformation?.position
|
||||
: x.demographicInformation?.employment,
|
||||
{
|
||||
id: "employment",
|
||||
header: "Employment",
|
||||
cell: (info) =>
|
||||
(info.row.original.type === "corporate"
|
||||
? info.getValue()
|
||||
: capitalize(info.getValue())) || "N/A",
|
||||
enableSorting: true,
|
||||
}
|
||||
),
|
||||
columnHelper.accessor("lastLogin", {
|
||||
header: "Last Login",
|
||||
cell: (info) =>
|
||||
!!info.getValue()
|
||||
? moment(info.getValue()).format("YYYY-MM-DD HH:mm")
|
||||
: "N/A",
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.gender", {
|
||||
header: "Gender",
|
||||
cell: (info) => capitalize(info.getValue()) || "N/A",
|
||||
enableSorting: true,
|
||||
}),
|
||||
{
|
||||
header: (
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowDemographicInformation((prev) => !prev)}
|
||||
>
|
||||
Switch
|
||||
</span>
|
||||
),
|
||||
id: "actions",
|
||||
cell: actionColumn,
|
||||
sortable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: ({ row, getValue }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
canEditUser(row.original) &&
|
||||
"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
|
||||
}>
|
||||
{getValue()}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: ({ row, getValue }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
canEditUser(row.original) &&
|
||||
"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)}>
|
||||
{getValue()}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("type", {
|
||||
header: "Type",
|
||||
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||
}),
|
||||
columnHelper.accessor("studentID", {
|
||||
header: "Student ID",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entities", {
|
||||
header: "Entities",
|
||||
cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '),
|
||||
}),
|
||||
columnHelper.accessor("subscriptionExpirationDate", {
|
||||
header: "Expiration",
|
||||
cell: (info) => (
|
||||
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
|
||||
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("isVerified", {
|
||||
header: "Verified",
|
||||
cell: (info) => (
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
|
||||
<div
|
||||
className={clsx(
|
||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
info.getValue() && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: (
|
||||
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
|
||||
Switch
|
||||
</span>
|
||||
),
|
||||
id: "actions",
|
||||
cell: actionColumn,
|
||||
sortable: false
|
||||
},
|
||||
];
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: ({ row, getValue }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
canEditUser(row.original) &&
|
||||
"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
|
||||
}
|
||||
>
|
||||
{getValue()}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: ({ row, getValue }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
canEditUser(row.original) &&
|
||||
"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
|
||||
}
|
||||
>
|
||||
{getValue()}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("type", {
|
||||
header: "Type",
|
||||
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||
}),
|
||||
columnHelper.accessor("studentID", {
|
||||
header: "Student ID",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entities", {
|
||||
header: "Entities",
|
||||
cell: ({ getValue }) => mapBy(getValue(), "label").join(", "),
|
||||
}),
|
||||
columnHelper.accessor("subscriptionExpirationDate", {
|
||||
header: "Expiration",
|
||||
cell: (info) => (
|
||||
<span
|
||||
className={clsx(
|
||||
info.getValue()
|
||||
? expirationDateColor(moment(info.getValue()).toDate())
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{!info.getValue()
|
||||
? "No expiry date"
|
||||
: moment(info.getValue()).format("DD/MM/YYYY")}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("isVerified", {
|
||||
header: "Verified",
|
||||
cell: (info) => (
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
|
||||
<div
|
||||
className={clsx(
|
||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
info.getValue() && "!bg-mti-purple-light "
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: (
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowDemographicInformation((prev) => !prev)}
|
||||
>
|
||||
Switch
|
||||
</span>
|
||||
),
|
||||
id: "actions",
|
||||
cell: actionColumn,
|
||||
sortable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
|
||||
if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.")
|
||||
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
|
||||
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 csv = exportListToExcel(allowedRows);
|
||||
const allowedRows = rows.filter((r) =>
|
||||
mapBy(r.entities, "id").some((e) =>
|
||||
mapBy(entitiesDownloadUsers, "id").includes(e)
|
||||
)
|
||||
);
|
||||
const csv = exportListToExcel(allowedRows);
|
||||
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([csv], { type: "text/csv" });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = "users.csv";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([csv], { type: "text/csv" });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = "users.csv";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
|
||||
const viewStudentFilter = (x: User) => x.type === "student";
|
||||
const viewTeacherFilter = (x: User) => x.type === "teacher";
|
||||
const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id));
|
||||
const viewStudentFilter = (x: User) => x.type === "student";
|
||||
const viewTeacherFilter = (x: User) => x.type === "teacher";
|
||||
const belongsToAdminFilter = (x: User) =>
|
||||
x.entities.some(({ id }) =>
|
||||
mapBy(selectedUser?.entities || [], "id").includes(id)
|
||||
);
|
||||
|
||||
const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x);
|
||||
const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x);
|
||||
const viewStudentFilterBelongsToAdmin = (x: User) =>
|
||||
viewStudentFilter(x) && belongsToAdminFilter(x);
|
||||
const viewTeacherFilterBelongsToAdmin = (x: User) =>
|
||||
viewTeacherFilter(x) && belongsToAdminFilter(x);
|
||||
|
||||
const renderUserCard = (selectedUser: User) => {
|
||||
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
|
||||
const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin);
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
maxUserAmount={0}
|
||||
loggedInUser={user}
|
||||
onViewStudents={
|
||||
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-students",
|
||||
filter: viewStudentFilter,
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: belongsToAdminFilter,
|
||||
});
|
||||
const renderUserCard = (selectedUser: User) => {
|
||||
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
|
||||
const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin);
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
maxUserAmount={0}
|
||||
loggedInUser={user}
|
||||
onViewStudents={
|
||||
(selectedUser.type === "corporate" ||
|
||||
selectedUser.type === "teacher") &&
|
||||
studentsFromAdmin.length > 0
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-students",
|
||||
filter: viewStudentFilter,
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: belongsToAdminFilter,
|
||||
});
|
||||
|
||||
router.push("/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: viewTeacherFilter,
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: belongsToAdminFilter,
|
||||
});
|
||||
router.push("/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
(selectedUser.type === "corporate" ||
|
||||
selectedUser.type === "student") &&
|
||||
teachersFromAdmin.length > 0
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: viewTeacherFilter,
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: belongsToAdminFilter,
|
||||
});
|
||||
|
||||
router.push("/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewCorporate={
|
||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-corporate",
|
||||
filter: (x: User) => x.type === "corporate",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: belongsToAdminFilter
|
||||
});
|
||||
router.push("/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewCorporate={
|
||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-corporate",
|
||||
filter: (x: User) => x.type === "corporate",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: belongsToAdminFilter,
|
||||
});
|
||||
|
||||
router.push("/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
router.push("/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderHeader && renderHeader(displayUsers.length)}
|
||||
<div className="w-full">
|
||||
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||
{selectedUser && renderUserCard(selectedUser)}
|
||||
</Modal>
|
||||
<Table<WithLabeledEntities<User>>
|
||||
data={displayUsers}
|
||||
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
|
||||
searchFields={searchFields}
|
||||
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{renderHeader && renderHeader(displayUsers.length)}
|
||||
<div className="w-full">
|
||||
<Modal
|
||||
isOpen={!!selectedUser}
|
||||
onClose={() => setSelectedUser(undefined)}
|
||||
>
|
||||
{selectedUser && renderUserCard(selectedUser)}
|
||||
</Modal>
|
||||
<Table<WithLabeledEntities<User>>
|
||||
data={displayUsers}
|
||||
columns={
|
||||
(!showDemographicInformation
|
||||
? defaultColumns
|
||||
: demographicColumns) as any
|
||||
}
|
||||
searchFields={searchFields}
|
||||
onDownload={
|
||||
entitiesDownloadUsers.length > 0 ? downloadExcel : undefined
|
||||
}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
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 Layout from "@/components/High/Layout";
|
||||
import { LayoutContext } from "@/components/High/Layout";
|
||||
import Finish from "@/exams/Finish";
|
||||
import Level from "@/exams/Level";
|
||||
import Listening from "@/exams/Listening";
|
||||
@@ -11,9 +11,12 @@ import Reading from "@/exams/Reading";
|
||||
import Selection from "@/exams/Selection";
|
||||
import Speaking from "@/exams/Speaking";
|
||||
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 { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation";
|
||||
import {
|
||||
evaluateSpeakingAnswer,
|
||||
evaluateWritingAnswer,
|
||||
} from "@/utils/evaluation";
|
||||
import { getExam } from "@/utils/exams";
|
||||
import axios from "axios";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -24,317 +27,436 @@ import useExamStore from "@/stores/exam";
|
||||
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
||||
|
||||
interface Props {
|
||||
page: "exams" | "exercises";
|
||||
user: User;
|
||||
destination?: string
|
||||
hideSidebar?: boolean
|
||||
page: "exams" | "exercises";
|
||||
user: User;
|
||||
destination?: string;
|
||||
hideSidebar?: boolean;
|
||||
}
|
||||
|
||||
export default function ExamPage({ page, user, destination = "/", hideSidebar = false }: Props) {
|
||||
const router = useRouter();
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||
const [pendingExercises, setPendingExercises] = useState<string[]>([]);
|
||||
export default function ExamPage({
|
||||
page,
|
||||
user,
|
||||
destination = "/",
|
||||
hideSidebar = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||
const [moduleLock, setModuleLock] = useState(false);
|
||||
|
||||
const {
|
||||
exam, setExam,
|
||||
exams,
|
||||
sessionId, setSessionId, setPartIndex,
|
||||
moduleIndex, setModuleIndex,
|
||||
setQuestionIndex, setExerciseIndex,
|
||||
userSolutions, setUserSolutions,
|
||||
showSolutions, setShowSolutions,
|
||||
selectedModules, setSelectedModules,
|
||||
setUser,
|
||||
inactivity,
|
||||
timeSpent,
|
||||
assignment,
|
||||
bgColor,
|
||||
flags,
|
||||
dispatch,
|
||||
reset: resetStore,
|
||||
saveStats,
|
||||
saveSession,
|
||||
setFlags,
|
||||
setShuffles,
|
||||
evaluated,
|
||||
} = useExamStore();
|
||||
const {
|
||||
exam,
|
||||
setExam,
|
||||
exams,
|
||||
sessionId,
|
||||
setSessionId,
|
||||
setPartIndex,
|
||||
moduleIndex,
|
||||
setModuleIndex,
|
||||
setQuestionIndex,
|
||||
setExerciseIndex,
|
||||
userSolutions,
|
||||
setUserSolutions,
|
||||
showSolutions,
|
||||
setShowSolutions,
|
||||
selectedModules,
|
||||
setSelectedModules,
|
||||
setUser,
|
||||
inactivity,
|
||||
timeSpent,
|
||||
assignment,
|
||||
bgColor,
|
||||
flags,
|
||||
dispatch,
|
||||
reset: resetStore,
|
||||
saveStats,
|
||||
saveSession,
|
||||
setFlags,
|
||||
setShuffles,
|
||||
} = useExamStore();
|
||||
|
||||
const [isFetchingExams, setIsFetchingExams] = useState(false);
|
||||
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length);
|
||||
const [isFetchingExams, setIsFetchingExams] = useState(false);
|
||||
const [isExamLoaded, setIsExamLoaded] = useState(
|
||||
moduleIndex < selectedModules.length
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsExamLoaded(moduleIndex < selectedModules.length);
|
||||
}, [showSolutions, moduleIndex, selectedModules]);
|
||||
useEffect(() => {
|
||||
setIsExamLoaded(moduleIndex < selectedModules.length);
|
||||
}, [showSolutions, moduleIndex, selectedModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && sessionId.length === 0 && user?.id) {
|
||||
const shortUID = new ShortUniqueId();
|
||||
setUser(user.id);
|
||||
setSessionId(shortUID.randomUUID(8));
|
||||
}
|
||||
}, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]);
|
||||
useEffect(() => {
|
||||
if (!showSolutions && sessionId.length === 0 && user?.id) {
|
||||
const shortUID = new ShortUniqueId();
|
||||
setUser(user.id);
|
||||
setSessionId(shortUID.randomUUID(8));
|
||||
}
|
||||
}, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.type === "developer") console.log(exam);
|
||||
}, [exam, user]);
|
||||
useEffect(() => {
|
||||
if (user?.type === "developer") console.log(exam);
|
||||
}, [exam, user]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedModules.length > 0 && exams.length === 0) {
|
||||
setIsFetchingExams(true);
|
||||
const examPromises = selectedModules.map((module) =>
|
||||
getExam(
|
||||
module,
|
||||
avoidRepeated,
|
||||
variant,
|
||||
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
|
||||
),
|
||||
);
|
||||
Promise.all(examPromises).then((values) => {
|
||||
setIsFetchingExams(false);
|
||||
if (values.every((x) => !!x)) {
|
||||
dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } })
|
||||
} else {
|
||||
toast.error("Something went wrong, please try again");
|
||||
setTimeout(router.reload, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedModules, exams]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedModules.length > 0 && exams.length === 0) {
|
||||
setIsFetchingExams(true);
|
||||
const examPromises = selectedModules.map((module) =>
|
||||
getExam(
|
||||
module,
|
||||
avoidRepeated,
|
||||
variant,
|
||||
user?.type === "student" || user?.type === "developer"
|
||||
? user.preferredGender
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
Promise.all(examPromises).then((values) => {
|
||||
setIsFetchingExams(false);
|
||||
if (values.every((x) => !!x)) {
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: {
|
||||
exams: values.map((x) => x!),
|
||||
modules: selectedModules,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.error("Something went wrong, please try again");
|
||||
setTimeout(router.reload, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedModules, exams]);
|
||||
|
||||
const reset = () => {
|
||||
resetStore();
|
||||
setVariant("full");
|
||||
setAvoidRepeated(false);
|
||||
setShowAbandonPopup(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
resetStore();
|
||||
setVariant("full");
|
||||
setAvoidRepeated(false);
|
||||
setShowAbandonPopup(false);
|
||||
};
|
||||
useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (flags.finalizeModule && !showSolutions && flags.pendingEvaluation) {
|
||||
if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) {
|
||||
const exercisesToEvaluate = exam.exercises
|
||||
.map(exercise => exercise.id);
|
||||
useEffect(() => {
|
||||
setModuleLock(true);
|
||||
}, [flags.finalizeModule]);
|
||||
|
||||
setPendingExercises(exercisesToEvaluate);
|
||||
(async () => {
|
||||
await Promise.all(
|
||||
exam.exercises.map(async (exercise, index) => {
|
||||
if (exercise.type === "writing")
|
||||
await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url);
|
||||
useEffect(() => {
|
||||
if (flags.finalizeModule && !showSolutions) {
|
||||
if (
|
||||
exam &&
|
||||
(exam.module === "writing" || exam.module === "speaking") &&
|
||||
userSolutions.length > 0
|
||||
) {
|
||||
(async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
exam.exercises.map(async (exercise, index) => {
|
||||
if (exercise.type === "writing") {
|
||||
const sol = await evaluateWritingAnswer(
|
||||
user.id,
|
||||
sessionId,
|
||||
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,
|
||||
sessionId,
|
||||
exercise,
|
||||
userSolutions.find((x) => x.exercise === exercise.id)!,
|
||||
index + 1
|
||||
);
|
||||
return sol;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
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,
|
||||
]);
|
||||
|
||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") {
|
||||
await evaluateSpeakingAnswer(
|
||||
user.id,
|
||||
sessionId,
|
||||
exercise,
|
||||
userSolutions.find((x) => x.exercise === exercise.id)!,
|
||||
index + 1,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]);
|
||||
useEffect(() => {
|
||||
if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) {
|
||||
(async () => {
|
||||
setModuleIndex(-1);
|
||||
await saveStats();
|
||||
await axios.get("/api/stats/update");
|
||||
})();
|
||||
}
|
||||
}, [
|
||||
flags.finalizeExam,
|
||||
moduleIndex,
|
||||
saveStats,
|
||||
setModuleIndex,
|
||||
userSolutions,
|
||||
moduleLock,
|
||||
flags.finalizeModule,
|
||||
]);
|
||||
|
||||
useEvaluationPolling({ pendingExercises, setPendingExercises });
|
||||
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
|
||||
}, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flags.finalizeExam && moduleIndex !== -1) {
|
||||
setModuleIndex(-1);
|
||||
const aggregateScoresByModule = (
|
||||
isPractice?: boolean
|
||||
): {
|
||||
module: Module;
|
||||
total: number;
|
||||
missing: number;
|
||||
correct: number;
|
||||
}[] => {
|
||||
const scores: {
|
||||
[key in Module]: { total: number; missing: number; correct: number };
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
|
||||
userSolutions.forEach((x) => {
|
||||
if (isPractice ? x.isPractice : !x.isPractice) {
|
||||
const examModule =
|
||||
x.module ||
|
||||
(x.type === "writing"
|
||||
? "writing"
|
||||
: x.type === "speaking" || x.type === "interactiveSpeaking"
|
||||
? "speaking"
|
||||
: undefined);
|
||||
|
||||
}
|
||||
}, [flags.finalizeExam, moduleIndex, setModuleIndex]);
|
||||
scores[examModule!] = {
|
||||
total: scores[examModule!].total + x.score.total,
|
||||
correct: scores[examModule!].correct + x.score.correct,
|
||||
missing: scores[examModule!].missing + x.score.missing,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
})
|
||||
);
|
||||
}
|
||||
await saveStats();
|
||||
await axios.get("/api/stats/update");
|
||||
setShowSolutions(true);
|
||||
setFlags({ finalizeExam: false });
|
||||
dispatch({ type: "UPDATE_EXAMS" })
|
||||
})();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saveStats, setFlags, setModuleIndex, evaluated, pendingExercises, setUserSolutions, flags]);
|
||||
return Object.keys(scores).reduce<
|
||||
{ module: Module; total: number; missing: number; correct: number }[]
|
||||
>((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>>> = {
|
||||
reading: Reading as React.ComponentType<ExamProps<Exam>>,
|
||||
listening: Listening as React.ComponentType<ExamProps<Exam>>,
|
||||
writing: Writing as React.ComponentType<ExamProps<Exam>>,
|
||||
speaking: Speaking as React.ComponentType<ExamProps<Exam>>,
|
||||
level: Level as React.ComponentType<ExamProps<Exam>>,
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (isPractice?: boolean): {
|
||||
module: Module;
|
||||
total: number;
|
||||
missing: number;
|
||||
correct: number;
|
||||
}[] => {
|
||||
const scores: {
|
||||
[key in Module]: { total: number; missing: number; correct: number };
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
|
||||
|
||||
userSolutions.filter(x => isPractice ? x.isPractice : !x.isPractice).forEach((x) => {
|
||||
const examModule =
|
||||
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined);
|
||||
const onAbandon = async () => {
|
||||
await saveSession();
|
||||
reset();
|
||||
};
|
||||
|
||||
scores[examModule!] = {
|
||||
total: scores[examModule!].total + x.score.total,
|
||||
correct: scores[examModule!].correct + x.score.correct,
|
||||
missing: scores[examModule!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
const {
|
||||
setBgColor,
|
||||
setHideSidebar,
|
||||
setFocusMode,
|
||||
setOnFocusLayerMouseEnter,
|
||||
} = React.useContext(LayoutContext);
|
||||
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||
};
|
||||
useEffect(() => {
|
||||
setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
|
||||
"reading": Reading as React.ComponentType<ExamProps<Exam>>,
|
||||
"listening": Listening as React.ComponentType<ExamProps<Exam>>,
|
||||
"writing": Writing as React.ComponentType<ExamProps<Exam>>,
|
||||
"speaking": Speaking as React.ComponentType<ExamProps<Exam>>,
|
||||
"level": Level as React.ComponentType<ExamProps<Exam>>,
|
||||
}
|
||||
useEffect(() => {
|
||||
setBgColor(bgColor);
|
||||
setHideSidebar(hideSidebar);
|
||||
setFocusMode(
|
||||
selectedModules.length !== 0 &&
|
||||
!showSolutions &&
|
||||
moduleIndex < selectedModules.length
|
||||
);
|
||||
}, [
|
||||
bgColor,
|
||||
hideSidebar,
|
||||
moduleIndex,
|
||||
selectedModules.length,
|
||||
setBgColor,
|
||||
setFocusMode,
|
||||
setHideSidebar,
|
||||
showSolutions,
|
||||
]);
|
||||
|
||||
const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
|
||||
|
||||
const onAbandon = async () => {
|
||||
await saveSession();
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
{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*/}
|
||||
{selectedModules.length === 0 && <Selection
|
||||
page={page}
|
||||
user={user!}
|
||||
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
|
||||
setModuleIndex(0);
|
||||
setAvoidRepeated(avoid);
|
||||
setSelectedModules(modules);
|
||||
setVariant(variant);
|
||||
}}
|
||||
/>}
|
||||
{isFetchingExams && (
|
||||
<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 className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}>Loading Exam ...</span>
|
||||
</div>
|
||||
)}
|
||||
{(moduleIndex === -1 && selectedModules.length !== 0) &&
|
||||
<Finish
|
||||
isLoading={flags.pendingEvaluation}
|
||||
user={user!}
|
||||
modules={selectedModules}
|
||||
solutions={userSolutions}
|
||||
assignment={assignment}
|
||||
information={{
|
||||
timeSpent,
|
||||
inactivity,
|
||||
}}
|
||||
destination={destination}
|
||||
onViewResults={(index?: number) => {
|
||||
if (exams[0].module === "level") {
|
||||
const levelExam = exams[0] as LevelExam;
|
||||
const allExercises = levelExam.parts.flatMap((part) => part.exercises);
|
||||
const exerciseOrderMap = new Map(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;
|
||||
});
|
||||
setUserSolutions(orderedSolutions);
|
||||
} else {
|
||||
setUserSolutions(userSolutions);
|
||||
}
|
||||
setShuffles([]);
|
||||
if (index === undefined) {
|
||||
setFlags({ reviewAll: true });
|
||||
setModuleIndex(0);
|
||||
setExam(exams[0]);
|
||||
} else {
|
||||
setModuleIndex(index);
|
||||
setExam(exams[index]);
|
||||
}
|
||||
setShowSolutions(true);
|
||||
setQuestionIndex(0);
|
||||
setExerciseIndex(0);
|
||||
setPartIndex(0);
|
||||
}}
|
||||
scores={aggregateScoresByModule()}
|
||||
practiceScores={aggregateScoresByModule(true)}
|
||||
/>}
|
||||
{/* Exam is on going, display it and the abandon modal */}
|
||||
{isExamLoaded && moduleIndex !== -1 && (
|
||||
<>
|
||||
{exam && CurrentExam && <CurrentExam exam={exam} showSolutions={showSolutions} />}
|
||||
{!showSolutions && <AbandonPopup
|
||||
isOpen={showAbandonPopup}
|
||||
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."
|
||||
abandonConfirmButtonText="Confirm"
|
||||
onAbandon={onAbandon}
|
||||
onCancel={() => setShowAbandonPopup(false)}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<>
|
||||
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
|
||||
{selectedModules.length === 0 && (
|
||||
<Selection
|
||||
page={page}
|
||||
user={user!}
|
||||
onStart={(
|
||||
modules: Module[],
|
||||
avoid: boolean,
|
||||
variant: Variant
|
||||
) => {
|
||||
setModuleIndex(0);
|
||||
setAvoidRepeated(avoid);
|
||||
setSelectedModules(modules);
|
||||
setVariant(variant);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isFetchingExams && (
|
||||
<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
|
||||
className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
|
||||
>
|
||||
Loading Exam ...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{moduleIndex === -1 && selectedModules.length !== 0 && (
|
||||
<Finish
|
||||
isLoading={userSolutions.some((s) => s.isDisabled)}
|
||||
user={user!}
|
||||
modules={selectedModules}
|
||||
solutions={userSolutions}
|
||||
assignment={assignment}
|
||||
information={{
|
||||
timeSpent,
|
||||
inactivity,
|
||||
}}
|
||||
destination={destination}
|
||||
onViewResults={(index?: number) => {
|
||||
if (exams[0].module === "level") {
|
||||
const levelExam = exams[0] as LevelExam;
|
||||
const allExercises = levelExam.parts.flatMap(
|
||||
(part) => part.exercises
|
||||
);
|
||||
const exerciseOrderMap = new Map(
|
||||
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;
|
||||
});
|
||||
setUserSolutions(orderedSolutions);
|
||||
} else {
|
||||
setUserSolutions(userSolutions);
|
||||
}
|
||||
setShuffles([]);
|
||||
if (index === undefined) {
|
||||
setFlags({ reviewAll: true });
|
||||
setModuleIndex(0);
|
||||
setExam(exams[0]);
|
||||
} else {
|
||||
setModuleIndex(index);
|
||||
setExam(exams[index]);
|
||||
}
|
||||
setShowSolutions(true);
|
||||
setQuestionIndex(0);
|
||||
setExerciseIndex(0);
|
||||
setPartIndex(0);
|
||||
}}
|
||||
scores={aggregateScoresByModule()}
|
||||
practiceScores={aggregateScoresByModule(true)}
|
||||
/>
|
||||
)}
|
||||
{/* Exam is on going, display it and the abandon modal */}
|
||||
{isExamLoaded && moduleIndex !== -1 && (
|
||||
<>
|
||||
{exam && CurrentExam && (
|
||||
<CurrentExam exam={exam} showSolutions={showSolutions} />
|
||||
)}
|
||||
{!showSolutions && (
|
||||
<AbandonPopup
|
||||
isOpen={showAbandonPopup}
|
||||
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."
|
||||
abandonConfirmButtonText="Confirm"
|
||||
onAbandon={onAbandon}
|
||||
onCancel={() => setShowAbandonPopup(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/* 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 { User } from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import { capitalize, sortBy } from "lodash";
|
||||
import { capitalize } from "lodash";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
import { useRouter } from "next/router";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import useDiscounts from "@/hooks/useDiscounts";
|
||||
import PaymobPayment from "@/components/PaymobPayment";
|
||||
import moment from "moment";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
@@ -22,241 +18,345 @@ import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import Select from "@/components/Low/Select";
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
discounts: Discount[]
|
||||
packages: Package[]
|
||||
entities: EntityWithRoles[]
|
||||
hasExpired?: boolean;
|
||||
reload: () => void;
|
||||
user: User;
|
||||
discounts: Discount[];
|
||||
packages: Package[];
|
||||
entities: EntityWithRoles[];
|
||||
hasExpired?: boolean;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [entity, setEntity] = useState<EntityWithRoles>()
|
||||
export default function PaymentDue({
|
||||
user,
|
||||
discounts = [],
|
||||
entities = [],
|
||||
packages = [],
|
||||
hasExpired = false,
|
||||
reload,
|
||||
}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [entity, setEntity] = useState<EntityWithRoles>();
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const { users } = useUsers();
|
||||
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id });
|
||||
const { users } = useUsers();
|
||||
const {
|
||||
invites,
|
||||
isLoading: isInvitesLoading,
|
||||
reload: reloadInvites,
|
||||
} = useInvites({ to: user?.id });
|
||||
|
||||
const isIndividual = useMemo(() => {
|
||||
if (isAdmin(user)) return false;
|
||||
if (user?.type !== "student") return false;
|
||||
const isIndividual = useMemo(() => {
|
||||
if (isAdmin(user)) return false;
|
||||
if (user?.type !== "student") return false;
|
||||
|
||||
return user.entities.length === 0
|
||||
}, [user])
|
||||
return user.entities.length === 0;
|
||||
}, [user]);
|
||||
|
||||
const appliedDiscount = useMemo(() => {
|
||||
const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift();
|
||||
const appliedDiscount = useMemo(() => {
|
||||
const biggestDiscount = [...discounts]
|
||||
.sort((a, b) => b.percentage - a.percentage)
|
||||
.shift();
|
||||
|
||||
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment())))
|
||||
return 0;
|
||||
if (
|
||||
!biggestDiscount ||
|
||||
(biggestDiscount.validUntil &&
|
||||
moment(biggestDiscount.validUntil).isBefore(moment()))
|
||||
)
|
||||
return 0;
|
||||
|
||||
return biggestDiscount.percentage
|
||||
}, [discounts])
|
||||
return biggestDiscount.percentage;
|
||||
}, [discounts]);
|
||||
|
||||
const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity')
|
||||
const entitiesThatCanBePaid = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"pay_entity"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0])
|
||||
}, [entitiesThatCanBePaid])
|
||||
useEffect(() => {
|
||||
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]);
|
||||
}, [entitiesThatCanBePaid]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
{isLoading && (
|
||||
<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">
|
||||
<span className={clsx("loading loading-infinity w-48 animate-pulse")} />
|
||||
<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
|
||||
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">
|
||||
Cancel Payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Layout user={user} navDisabled={hasExpired}>
|
||||
{invites.length > 0 && (
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
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">
|
||||
<span className="text-mti-black text-lg font-bold">Invites</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{invites.map((invite) => (
|
||||
<InviteCard
|
||||
key={invite.id}
|
||||
invite={invite}
|
||||
users={users}
|
||||
reload={() => {
|
||||
reloadInvites();
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
{isLoading && (
|
||||
<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">
|
||||
<span
|
||||
className={clsx("loading loading-infinity w-48 animate-pulse")}
|
||||
/>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
Cancel Payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
{invites.length > 0 && (
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<span className="text-mti-black text-lg font-bold">
|
||||
Invites
|
||||
</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx(
|
||||
"text-xl",
|
||||
isInvitesLoading && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{invites.map((invite) => (
|
||||
<InviteCard
|
||||
key={invite.id}
|
||||
invite={invite}
|
||||
users={users}
|
||||
reload={() => {
|
||||
reloadInvites();
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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>}
|
||||
{isIndividual && (
|
||||
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach, please purchase one of the time packages available below:
|
||||
</span>
|
||||
<div className="flex w-full flex-wrap justify-center gap-8">
|
||||
{packages.map((p) => (
|
||||
<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">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="text-xl font-semibold">
|
||||
EnCoach - {p.duration}{" "}
|
||||
{capitalize(
|
||||
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
{appliedDiscount === 0 && (
|
||||
<span className="text-2xl">
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
)}
|
||||
{appliedDiscount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl line-through">
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
<span className="text-2xl text-mti-red-light">
|
||||
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PaymobPayment
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
currency={p.currency}
|
||||
duration={p.duration}
|
||||
duration_unit={p.duration_unit}
|
||||
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>- Train your abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your weaknesses and strengths</li>
|
||||
<li>- Allow yourself to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{isIndividual && (
|
||||
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach, please purchase one of the time
|
||||
packages available below:
|
||||
</span>
|
||||
<div className="flex w-full flex-wrap justify-center gap-8">
|
||||
{packages.map((p) => (
|
||||
<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">
|
||||
<img
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach's Logo"
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xl font-semibold">
|
||||
EnCoach - {p.duration}{" "}
|
||||
{capitalize(
|
||||
p.duration === 1
|
||||
? p.duration_unit.slice(
|
||||
0,
|
||||
p.duration_unit.length - 1
|
||||
)
|
||||
: p.duration_unit
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
{appliedDiscount === 0 && (
|
||||
<span className="text-2xl">
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
)}
|
||||
{appliedDiscount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl line-through">
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
<span className="text-2xl text-mti-red-light">
|
||||
{(
|
||||
p.price -
|
||||
p.price * (appliedDiscount / 100)
|
||||
).toFixed(2)}{" "}
|
||||
{p.currency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PaymobPayment
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
currency={p.currency}
|
||||
duration={p.duration}
|
||||
duration_unit={p.duration_unit}
|
||||
price={
|
||||
+(
|
||||
p.price -
|
||||
p.price * (appliedDiscount / 100)
|
||||
).toFixed(2)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>- Train your abilities for the IELTS exam</li>
|
||||
<li>
|
||||
- Gain insights into your weaknesses and strengths
|
||||
</li>
|
||||
<li>
|
||||
- Allow yourself to correctly prepare for the exam
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isIndividual && entitiesThatCanBePaid.length > 0 &&
|
||||
entity?.payment && (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<Select
|
||||
defaultValue={{ value: entity?.id, 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"
|
||||
/>
|
||||
</div>
|
||||
{!isIndividual &&
|
||||
entitiesThatCanBePaid.length > 0 &&
|
||||
entity?.payment && (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div
|
||||
className={clsx("flex flex-col items-center gap-4 w-full")}
|
||||
>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{ value: entity?.id, 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach and that of your students and teachers, please pay your designated package
|
||||
below:
|
||||
</span>
|
||||
<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">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="text-xl font-semibold">
|
||||
EnCoach - {12} Months
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<span className="text-2xl">
|
||||
{entity.payment.price} {entity.payment.currency}
|
||||
</span>
|
||||
<PaymobPayment
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
entity={entity}
|
||||
currency={entity.payment.currency}
|
||||
price={entity.payment.price}
|
||||
duration={12}
|
||||
duration_unit="months"
|
||||
onSuccess={() => {
|
||||
setIsLoading(false);
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>
|
||||
- Allow a total of {entity.licenses} students and teachers to use EnCoach
|
||||
</li>
|
||||
<li>- Train their abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your students' weaknesses and strengths</li>
|
||||
<li>- Allow them to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isIndividual && entitiesThatCanBePaid.length === 0 && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="max-w-lg">
|
||||
You are not the person in charge of your time credits, please contact your administrator about this situation.
|
||||
</span>
|
||||
<span className="max-w-lg">
|
||||
If you believe this to be a mistake, please contact the platform's administration, thank you for your
|
||||
patience.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isIndividual &&
|
||||
entitiesThatCanBePaid.length > 0 &&
|
||||
!entity?.payment && (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<Select
|
||||
defaultValue={{ value: entity?.id || "", 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"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
you desire and your expected monthly duration.
|
||||
</span>
|
||||
<span className="max-w-lg">
|
||||
Please try again later or contact your agent or an admin, thank you for your patience.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach and that of your students and
|
||||
teachers, please pay your designated package below:
|
||||
</span>
|
||||
<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">
|
||||
<img
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach's Logo"
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xl font-semibold">
|
||||
EnCoach - {12} Months
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<span className="text-2xl">
|
||||
{entity.payment.price} {entity.payment.currency}
|
||||
</span>
|
||||
<PaymobPayment
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
entity={entity}
|
||||
currency={entity.payment.currency}
|
||||
price={entity.payment.price}
|
||||
duration={12}
|
||||
duration_unit="months"
|
||||
onSuccess={() => {
|
||||
setIsLoading(false);
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>
|
||||
- Allow a total of {entity.licenses} students and
|
||||
teachers to use EnCoach
|
||||
</li>
|
||||
<li>- Train their abilities for the IELTS exam</li>
|
||||
<li>
|
||||
- Gain insights into your students' weaknesses and
|
||||
strengths
|
||||
</li>
|
||||
<li>- Allow them to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isIndividual && entitiesThatCanBePaid.length === 0 && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="max-w-lg">
|
||||
You are not the person in charge of your time credits, please
|
||||
contact your administrator about this situation.
|
||||
</span>
|
||||
<span className="max-w-lg">
|
||||
If you believe this to be a mistake, please contact the
|
||||
platform's administration, thank you for your patience.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isIndividual &&
|
||||
entitiesThatCanBePaid.length > 0 &&
|
||||
!entity?.payment && (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div
|
||||
className={clsx("flex flex-col items-center gap-4 w-full")}
|
||||
>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{
|
||||
value: entity?.id || "",
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<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 you desire
|
||||
and your expected monthly duration.
|
||||
</span>
|
||||
<span className="max-w-lg">
|
||||
Please try again later or contact your agent or an admin,
|
||||
thank you for your patience.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
import "@/styles/globals.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/primereact.min.css";
|
||||
import "primeicons/primeicons.css";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect} from "react";
|
||||
import { Router, useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import useExamStore from "@/stores/exam";
|
||||
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 setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
|
||||
const { reset } = useExamStore();
|
||||
|
||||
const router = useRouter();
|
||||
const setIsSidebarMinimized = usePreferencesStore(
|
||||
(state) => state.setSidebarMinimized
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.pathname !== "/exam" && router.pathname !== "/exercises") reset();
|
||||
}, [router.pathname, reset]);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("isSidebarMinimized")) {
|
||||
if (localStorage.getItem("isSidebarMinimized") === "true") {
|
||||
setIsSidebarMinimized(true);
|
||||
} else {
|
||||
setIsSidebarMinimized(false);
|
||||
}
|
||||
}
|
||||
}, [setIsSidebarMinimized]);
|
||||
const { entities } = useEntities(!pageProps?.user?.id);
|
||||
|
||||
return <Component {...pageProps} />;
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("isSidebarMinimized")) {
|
||||
if (localStorage.getItem("isSidebarMinimized") === "true") {
|
||||
setIsSidebarMinimized(true);
|
||||
} else {
|
||||
setIsSidebarMinimized(false);
|
||||
}
|
||||
}
|
||||
}, [setIsSidebarMinimized]);
|
||||
|
||||
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} />
|
||||
);
|
||||
}
|
||||
|
||||
32
src/pages/api/approval-workflows/[id]/edit.ts
Normal file
32
src/pages/api/approval-workflows/[id]/edit.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
62
src/pages/api/approval-workflows/[id]/index.ts
Normal file
62
src/pages/api/approval-workflows/[id]/index.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
37
src/pages/api/approval-workflows/create.ts
Normal file
37
src/pages/api/approval-workflows/create.ts
Normal 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();
|
||||
}
|
||||
78
src/pages/api/approval-workflows/index.ts
Normal file
78
src/pages/api/approval-workflows/index.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,71 @@ import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { UserSolution } from "@/interfaces/exam";
|
||||
import { speakingReverseMarking, writingReverseMarking } from "@/utils/score";
|
||||
import { Stat } from "@/interfaces/user";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
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) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
const { sessionId, userId, userSolutions } = req.body;
|
||||
try {
|
||||
return await getSessionEvals(req, res);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
function formatSolutionWithEval(userSolution: UserSolution | Stat, evaluation: any) {
|
||||
if (userSolution.type === 'writing') {
|
||||
return {
|
||||
...userSolution,
|
||||
solutions: [{
|
||||
...userSolution.solutions[0],
|
||||
evaluation: evaluation.result
|
||||
}],
|
||||
score: {
|
||||
correct: writingReverseMarking[evaluation.result.overall],
|
||||
total: 100,
|
||||
missing: 0
|
||||
},
|
||||
isDisabled: false
|
||||
};
|
||||
}
|
||||
|
||||
if (userSolution.type === 'speaking' || userSolution.type === 'interactiveSpeaking') {
|
||||
return {
|
||||
...userSolution,
|
||||
solutions: [{
|
||||
...userSolution.solutions[0],
|
||||
...(
|
||||
userSolution.type === 'speaking'
|
||||
? { fullPath: evaluation.result.fullPath }
|
||||
: { answer: evaluation.result.answer }
|
||||
),
|
||||
evaluation: evaluation.result
|
||||
}],
|
||||
score: {
|
||||
correct: speakingReverseMarking[evaluation.result.overall || 0] || 0,
|
||||
total: 100,
|
||||
missing: 0
|
||||
},
|
||||
isDisabled: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
solution: userSolution,
|
||||
evaluation
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -29,52 +79,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
completedEvals.map(e => [e.exercise_id, e])
|
||||
);
|
||||
|
||||
const solutionsWithEvals = userSolutions.filter((solution: UserSolution) =>
|
||||
evalsByExercise.has(solution.exercise)
|
||||
).map((solution: any) => {
|
||||
const evaluation = evalsByExercise.get(solution.exercise)!;
|
||||
const statsWithEvals = stats
|
||||
.filter((solution: UserSolution | Stat) => evalsByExercise.has(solution.exercise))
|
||||
.map((solution: UserSolution | Stat) =>
|
||||
formatSolutionWithEval(solution, evalsByExercise.get(solution.exercise)!)
|
||||
);
|
||||
|
||||
if (solution.type === 'writing') {
|
||||
return {
|
||||
...solution,
|
||||
solutions: [{
|
||||
...solution.solutions[0],
|
||||
evaluation: evaluation.result
|
||||
}],
|
||||
score: {
|
||||
correct: writingReverseMarking[evaluation.result.overall],
|
||||
total: 100,
|
||||
missing: 0
|
||||
},
|
||||
isDisabled: false
|
||||
};
|
||||
}
|
||||
|
||||
if (solution.type === 'speaking' || solution.type === 'interactiveSpeaking') {
|
||||
return {
|
||||
...solution,
|
||||
solutions: [{
|
||||
...solution.solutions[0],
|
||||
...(
|
||||
solution.type === 'speaking'
|
||||
? { fullPath: evaluation.result.fullPath }
|
||||
: { answer: evaluation.result.answer }
|
||||
),
|
||||
evaluation: evaluation.result
|
||||
}],
|
||||
score: {
|
||||
correct: speakingReverseMarking[evaluation.result.overall || 0] || 0,
|
||||
total: 100,
|
||||
missing: 0
|
||||
},
|
||||
isDisabled: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
solution,
|
||||
evaluation
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json(solutionsWithEvals)
|
||||
res.status(200).json(statsWithEvals);
|
||||
}
|
||||
|
||||
@@ -11,19 +11,100 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
}
|
||||
|
||||
type Query = {
|
||||
op: string;
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
return res.status(401).json({ ok: false });
|
||||
}
|
||||
|
||||
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({
|
||||
session_id: sessionId,
|
||||
user: userId,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
res.status(200).json({ hasPendingEvaluation: singleEval !== null});
|
||||
return 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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,13 +40,13 @@ async function GET(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 });
|
||||
|
||||
const { module } = req.query as { module: string };
|
||||
|
||||
const session = client.startSession();
|
||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
|
||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
|
||||
|
||||
try {
|
||||
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
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -73,13 +76,12 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
{ $set: { id: req.body.id, ...exam } },
|
||||
{
|
||||
upsert: true,
|
||||
session
|
||||
session,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
res.status(200).json(exam);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Transaction failed: ", error);
|
||||
res.status(500).json({ ok: false, error: (error as any).message });
|
||||
|
||||
@@ -26,7 +26,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const q = user ? { user: user } : {};
|
||||
const sessions = await db.collection("sessions").find<Session>({
|
||||
...q,
|
||||
}).limit(12).toArray();
|
||||
}).limit(12).sort({ date: -1 }).toArray();
|
||||
console.log(sessions)
|
||||
|
||||
res.status(200).json(
|
||||
|
||||
@@ -3,37 +3,41 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Stat } from "@/interfaces/user";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { UserSolution } from "@/interfaces/exam";
|
||||
import { WithId } from "mongodb";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
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)
|
||||
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) => {
|
||||
const matchingSolution = solutions.find(s => s.exercise === stat.exercise);
|
||||
if (matchingSolution) {
|
||||
const { _id, ...updateFields } = matchingSolution as WithId<UserSolution>;
|
||||
await db.collection("stats").updateOne(
|
||||
{ id: stat.id },
|
||||
{ $set: { ...matchingSolution } }
|
||||
{ $set: { ...updateFields } }
|
||||
);
|
||||
}
|
||||
}));
|
||||
21
src/pages/api/stats/session/[session].ts
Normal file
21
src/pages/api/stats/session/[session].ts
Normal 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);
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
// 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";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { getDetailedStatsByUser } from "../../../../utils/stats.be";
|
||||
|
||||
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});
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const {user} = req.query;
|
||||
const snapshot = await db.collection("stats").find({ user: user }).toArray();
|
||||
const { user, query } = req.query as { user: string, query?: string };
|
||||
|
||||
const snapshot = await getDetailedStatsByUser(user, query);
|
||||
res.status(200).json(snapshot);
|
||||
}
|
||||
39
src/pages/api/tickets/assignedToUser/index.ts
Normal file
39
src/pages/api/tickets/assignedToUser/index.ts
Normal 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);
|
||||
}
|
||||
39
src/pages/api/users/search.ts
Normal file
39
src/pages/api/users/search.ts
Normal 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 });
|
||||
}
|
||||
192
src/pages/approval-workflows/[id]/edit.tsx
Normal file
192
src/pages/approval-workflows/[id]/edit.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
559
src/pages/approval-workflows/[id]/index.tsx
Normal file
559
src/pages/approval-workflows/[id]/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
412
src/pages/approval-workflows/create.tsx
Normal file
412
src/pages/approval-workflows/create.tsx
Normal 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 isn’t 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
417
src/pages/approval-workflows/index.tsx
Normal file
417
src/pages/approval-workflows/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,229 +1,336 @@
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Separator from "@/components/Low/Separator";
|
||||
import AssignmentCard from "@/components/AssignmentCard";
|
||||
import AssignmentView from "@/components/AssignmentView";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import usePagination from "@/hooks/usePagination";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { CorporateUser, Group, User } from "@/interfaces/user";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { getUserCompanyName } from "@/resources/user";
|
||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import {
|
||||
activeAssignmentFilter,
|
||||
archivedAssignmentFilter,
|
||||
futureAssignmentFilter,
|
||||
pastAssignmentFilter,
|
||||
startHasExpiredAssignmentFilter,
|
||||
activeAssignmentFilter,
|
||||
archivedAssignmentFilter,
|
||||
futureAssignmentFilter,
|
||||
pastAssignmentFilter,
|
||||
startHasExpiredAssignmentFilter,
|
||||
} from "@/utils/assignments";
|
||||
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { groupBy } from "lodash";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { BsChevronLeft, BsPlus } from "react-icons/bs";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
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 entities = await (isAdmin
|
||||
? getEntitiesWithRoles()
|
||||
: getEntitiesWithRoles(entityIDS));
|
||||
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
|
||||
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 allowedEntities = findAllowedEntities(user, entities, "view_assignments")
|
||||
|
||||
const users =
|
||||
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
|
||||
|
||||
const assignments =
|
||||
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
|
||||
|
||||
return { props: serialize({ user, users, entities: allowedEntities, assignments }) };
|
||||
return {
|
||||
props: serialize({ user, users, entities: allowedEntities, assignments }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
const SEARCH_FIELDS = [["name"]];
|
||||
|
||||
interface Props {
|
||||
assignments: Assignment[];
|
||||
entities: EntityWithRoles[]
|
||||
user: User;
|
||||
users: User[];
|
||||
assignments: Assignment[];
|
||||
entities: EntityWithRoles[];
|
||||
user: User;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export default function AssignmentsPage({ assignments, entities, user, users }: Props) {
|
||||
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment')
|
||||
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment')
|
||||
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment')
|
||||
export default function AssignmentsPage({
|
||||
assignments,
|
||||
entities,
|
||||
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 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 activeAssignments = useMemo(
|
||||
() => assignments.filter(activeAssignmentFilter),
|
||||
[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: 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 { rows: activeRows, renderSearch: renderActive } = useListSearch(
|
||||
SEARCH_FIELDS,
|
||||
activeAssignments
|
||||
);
|
||||
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: 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);
|
||||
const { items: activeItems, renderMinimal: paginationActive } = usePagination(
|
||||
activeRows,
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>Assignments | 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>
|
||||
<Layout user={user}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">Assignments</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/
|
||||
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Assignments | 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>
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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"
|
||||
>
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">Assignments</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
<b>Total:</b>{" "}
|
||||
{activeAssignments.reduce(
|
||||
(acc, curr) => acc + curr.results.length,
|
||||
0
|
||||
)}
|
||||
/
|
||||
{activeAssignments.reduce(
|
||||
(acc, curr) => curr.exams.length + acc,
|
||||
0
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Active Assignments ({activeAssignments.length})</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderActive()}
|
||||
{paginationActive()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeItems.map((a) => (
|
||||
<AssignmentCard {...a} entityObj={findBy(entities, 'id', a.entity)} users={users} onClick={() => router.push(`/assignments/${a.id}`)} key={a.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Active Assignments ({activeAssignments.length})
|
||||
</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderActive()}
|
||||
{paginationActive()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
entityObj={findBy(entities, "id", a.entity)}
|
||||
users={users}
|
||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||
key={a.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Planned Assignments ({plannedAssignments.length})</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderPlanned()}
|
||||
{paginationPlanned()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={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" />
|
||||
<span className="text-lg">New Assignment</span>
|
||||
</Link>
|
||||
{plannedItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
entityObj={findBy(entities, 'id', a.entity)}
|
||||
onClick={
|
||||
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "")
|
||||
? () => router.push(`/assignments/creator/${a.id}`)
|
||||
: () => router.push(`/assignments/${a.id}`)
|
||||
}
|
||||
key={a.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Planned Assignments ({plannedAssignments.length})
|
||||
</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderPlanned()}
|
||||
{paginationPlanned()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={
|
||||
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" />
|
||||
<span className="text-lg">New Assignment</span>
|
||||
</Link>
|
||||
{plannedItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
entityObj={findBy(entities, "id", a.entity)}
|
||||
onClick={
|
||||
mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
|
||||
? () => router.push(`/assignments/creator/${a.id}`)
|
||||
: () => router.push(`/assignments/${a.id}`)
|
||||
}
|
||||
key={a.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderPast()}
|
||||
{paginationPast()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pastItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
entityObj={findBy(entities, 'id', a.entity)}
|
||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
|
||||
allowExcelDownload
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Assignments start expired ({startExpiredAssignments.length})</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderExpired()}
|
||||
{paginationExpired()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{expiredItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
entityObj={findBy(entities, 'id', a.entity)}
|
||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
|
||||
allowExcelDownload
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Archived Assignments ({archivedAssignments.length})</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderArchived()}
|
||||
{paginationArchived()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{archivedItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||
key={a.id}
|
||||
entityObj={findBy(entities, 'id', a.entity)}
|
||||
allowDownload
|
||||
allowUnarchive
|
||||
allowExcelDownload
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Past Assignments ({pastAssignments.length})
|
||||
</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderPast()}
|
||||
{paginationPast()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pastItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
entityObj={findBy(entities, "id", a.entity)}
|
||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
|
||||
a.entity || ""
|
||||
)}
|
||||
allowExcelDownload
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Assignments start expired ({startExpiredAssignments.length})
|
||||
</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderExpired()}
|
||||
{paginationExpired()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{expiredItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
entityObj={findBy(entities, "id", a.entity)}
|
||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
|
||||
a.entity || ""
|
||||
)}
|
||||
allowExcelDownload
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Archived Assignments ({archivedAssignments.length})
|
||||
</h2>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderArchived()}
|
||||
{paginationArchived()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{archivedItems.map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
users={users}
|
||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||
key={a.id}
|
||||
entityObj={findBy(entities, "id", a.entity)}
|
||||
allowDownload
|
||||
allowUnarchive
|
||||
allowExcelDownload
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Tooltip from "@/components/Low/Tooltip";
|
||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
@@ -19,328 +18,476 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { capitalize } from "lodash";
|
||||
import { capitalize, last } from "lodash";
|
||||
import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Divider } from "primereact/divider";
|
||||
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";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
export const getServerSideProps = withIronSessionSsr(
|
||||
async ({ req, res, params }) => {
|
||||
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);
|
||||
if (!group || !group.entity) return redirect("/classrooms")
|
||||
const group = await getGroup(id);
|
||||
if (!group || !group.entity) return redirect("/classrooms");
|
||||
|
||||
const entity = await getEntityWithRoles(group.entity)
|
||||
if (!entity) return redirect("/classrooms")
|
||||
const entity = await getEntityWithRoles(group.entity);
|
||||
if (!entity) return redirect("/classrooms");
|
||||
|
||||
const canView = doesEntityAllow(user, entity, "view_classrooms")
|
||||
if (!canView) return redirect("/")
|
||||
const canView = doesEntityAllow(user, entity, "view_classrooms");
|
||||
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 {
|
||||
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
group: groupWithUser,
|
||||
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
|
||||
entity,
|
||||
}),
|
||||
};
|
||||
},
|
||||
sessionOptions
|
||||
);
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
group: GroupWithUsers;
|
||||
users: User[];
|
||||
entity: EntityWithRoles
|
||||
user: User;
|
||||
group: GroupWithUsers;
|
||||
users: User[];
|
||||
entity: EntityWithRoles;
|
||||
}
|
||||
|
||||
export default function Home({ user, group, users, entity }: Props) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
|
||||
const canAddParticipants = useEntityPermission(user, entity, "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 canAddParticipants = useEntityPermission(
|
||||
user,
|
||||
entity,
|
||||
"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(
|
||||
() => 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 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]
|
||||
);
|
||||
|
||||
const { rows, renderSearch } = useListSearch<User>(
|
||||
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||
isAdding ? nonParticipantUsers : group.participants,
|
||||
);
|
||||
const { items, renderMinimal } = usePagination<User>(rows, 20);
|
||||
const { rows, renderSearch } = useListSearch<User>(
|
||||
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||
isAdding ? nonParticipantUsers : group.participants
|
||||
);
|
||||
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 = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
if (!canRemoveParticipants) return;
|
||||
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`))
|
||||
return;
|
||||
const removeParticipants = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
if (!canRemoveParticipants) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to remove ${selectedUsers.length} participant${
|
||||
selectedUsers.length === 1 ? "" : "s"
|
||||
} from this group?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) })
|
||||
.then(() => {
|
||||
toast.success("The group has been updated successfully!");
|
||||
router.replace(router.asPath);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
axios
|
||||
.patch(`/api/groups/${group.id}`, {
|
||||
participants: group.participants
|
||||
.map((x) => x.id)
|
||||
.filter((x) => !selectedUsers.includes(x)),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("The group has been updated successfully!");
|
||||
router.replace(router.asPath);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const addParticipants = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
if (!canAddParticipants || !isAdding) return;
|
||||
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`))
|
||||
return;
|
||||
const addParticipants = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
if (!canAddParticipants || !isAdding) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to add ${selectedUsers.length} participant${
|
||||
selectedUsers.length === 1 ? "" : "s"
|
||||
} to this group?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] })
|
||||
.then(() => {
|
||||
toast.success("The group has been updated successfully!");
|
||||
router.replace(router.asPath);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
axios
|
||||
.patch(`/api/groups/${group.id}`, {
|
||||
participants: [
|
||||
...group.participants.map((x) => x.id),
|
||||
...selectedUsers,
|
||||
],
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("The group has been updated successfully!");
|
||||
router.replace(router.asPath);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const renameGroup = () => {
|
||||
if (!canRenameClassroom) return;
|
||||
const renameGroup = () => {
|
||||
if (!canRenameClassroom) return;
|
||||
|
||||
const name = prompt("Rename this classroom:", group.name);
|
||||
if (!name) return;
|
||||
const name = prompt("Rename this classroom:", group.name);
|
||||
if (!name) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.patch(`/api/groups/${group.id}`, { name })
|
||||
.then(() => {
|
||||
toast.success("The classroom has been updated successfully!");
|
||||
router.replace(router.asPath);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.patch(`/api/groups/${group.id}`, { name })
|
||||
.then(() => {
|
||||
toast.success("The classroom has been updated successfully!");
|
||||
router.replace(router.asPath);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const deleteGroup = () => {
|
||||
if (!canDeleteClassroom) return;
|
||||
if (!confirm("Are you sure you want to delete this classroom?")) return;
|
||||
const deleteGroup = () => {
|
||||
if (!canDeleteClassroom) return;
|
||||
if (!confirm("Are you sure you want to delete this classroom?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.delete(`/api/groups/${group.id}`)
|
||||
.then(() => {
|
||||
toast.success("This classroom has been successfully deleted!");
|
||||
router.replace("/classrooms");
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
axios
|
||||
.delete(`/api/groups/${group.id}`)
|
||||
.then(() => {
|
||||
toast.success("This classroom has been successfully deleted!");
|
||||
router.replace("/classrooms");
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedUsers([]), [isAdding]);
|
||||
useEffect(() => setSelectedUsers([]), [isAdding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{group.name} | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user}>
|
||||
<section className="flex flex-col gap-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/classrooms"
|
||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">{group.name}</h2>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{group.name} | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<>
|
||||
<section className="flex flex-col gap-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/classrooms"
|
||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||
>
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">{group.name}</h2>
|
||||
</div>
|
||||
|
||||
{!isAdding && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={renameGroup}
|
||||
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">
|
||||
<BsTag />
|
||||
<span className="text-xs">Rename Classroom</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteGroup}
|
||||
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">
|
||||
<BsTrash />
|
||||
<span className="text-xs">Delete Classroom</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<BsBuilding className="text-xl" /> {entity.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-semibold text-xl">Participants</span>
|
||||
{!isAdding && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
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">
|
||||
<BsPlus />
|
||||
<span className="text-xs">Add Participants</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={removeParticipants}
|
||||
disabled={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 />
|
||||
<span className="text-xs">Remove Participants</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isAdding && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsAdding(false)}
|
||||
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">
|
||||
<BsX />
|
||||
<span className="text-xs">Discard Selection</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={addParticipants}
|
||||
disabled={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 />
|
||||
<span className="text-xs">Add Participants</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderSearch()}
|
||||
{renderMinimal()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{['student', 'teacher', 'corporate'].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => {
|
||||
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id')
|
||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
|
||||
} else {
|
||||
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
|
||||
}
|
||||
}}
|
||||
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0}
|
||||
className={clsx(
|
||||
"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",
|
||||
"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(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white",
|
||||
)}>
|
||||
{capitalize(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{!isAdding && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={renameGroup}
|
||||
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"
|
||||
>
|
||||
<BsTag />
|
||||
<span className="text-xs">Rename Classroom</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteGroup}
|
||||
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"
|
||||
>
|
||||
<BsTrash />
|
||||
<span className="text-xs">Delete Classroom</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<BsBuilding className="text-xl" /> {entity.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<BsFillPersonVcardFill className="text-xl" />{" "}
|
||||
{getUserName(group.admin)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-semibold text-xl">Participants</span>
|
||||
{!isAdding && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
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"
|
||||
>
|
||||
<BsPlus />
|
||||
<span className="text-xs">Add Participants</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={removeParticipants}
|
||||
disabled={
|
||||
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 />
|
||||
<span className="text-xs">Remove Participants</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isAdding && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsAdding(false)}
|
||||
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"
|
||||
>
|
||||
<BsX />
|
||||
<span className="text-xs">Discard Selection</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={addParticipants}
|
||||
disabled={
|
||||
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 />
|
||||
<span className="text-xs">Add Participants</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderSearch()}
|
||||
{renderMinimal()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{["student", "teacher", "corporate"].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => {
|
||||
const typeUsers = mapBy(
|
||||
filterBy(
|
||||
isAdding ? nonParticipantUsers : group.participants,
|
||||
"type",
|
||||
type
|
||||
),
|
||||
"id"
|
||||
);
|
||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.filter((a) => !typeUsers.includes(a))
|
||||
);
|
||||
} else {
|
||||
setSelectedUsers((prev) => [
|
||||
...prev.filter((a) => !typeUsers.includes(a)),
|
||||
...typeUsers,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
filterBy(
|
||||
isAdding ? nonParticipantUsers : group.participants,
|
||||
"type",
|
||||
type
|
||||
).length === 0
|
||||
}
|
||||
className={clsx(
|
||||
"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",
|
||||
"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(
|
||||
isAdding ? nonParticipantUsers : group.participants,
|
||||
"type",
|
||||
type
|
||||
).every((u) => selectedUsers.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{capitalize(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{items.map((u) => (
|
||||
<button
|
||||
onClick={() => toggleUser(u)}
|
||||
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants}
|
||||
key={u.id}
|
||||
className={clsx(
|
||||
"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",
|
||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
||||
)}>
|
||||
<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">
|
||||
<img src={u.profilePicture} alt={u.name} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{getUserName(u)}</span>
|
||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{items.map((u) => (
|
||||
<button
|
||||
onClick={() => toggleUser(u)}
|
||||
disabled={
|
||||
isAdding ? !canAddParticipants : !canRemoveParticipants
|
||||
}
|
||||
key={u.id}
|
||||
className={clsx(
|
||||
"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",
|
||||
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<img src={u.profilePicture} alt={u.name} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{getUserName(u)}</span>
|
||||
<span className="opacity-80 text-sm">
|
||||
{USER_TYPE_LABELS[u.type]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="E-mail address">
|
||||
<BsEnvelopeFill />
|
||||
</Tooltip>
|
||||
{u.email}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Expiration Date">
|
||||
<BsStopwatchFill />
|
||||
</Tooltip>
|
||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Last Login">
|
||||
<BsClockFill />
|
||||
</Tooltip>
|
||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="E-mail address">
|
||||
<BsEnvelopeFill />
|
||||
</Tooltip>
|
||||
{u.email}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Expiration Date">
|
||||
<BsStopwatchFill />
|
||||
</Tooltip>
|
||||
{u.subscriptionExpirationDate
|
||||
? moment(u.subscriptionExpirationDate).format(
|
||||
"DD/MM/YYYY"
|
||||
)
|
||||
: "Unlimited"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Last Login">
|
||||
<BsClockFill />
|
||||
</Tooltip>
|
||||
{u.lastLogin
|
||||
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,223 +1,309 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Input from "@/components/Low/Input";
|
||||
import Select from "@/components/Low/Select";
|
||||
import Tooltip from "@/components/Low/Tooltip";
|
||||
import {useListSearch} from "@/hooks/useListSearch";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import usePagination from "@/hooks/usePagination";
|
||||
import {Entity, EntityWithRoles} from "@/interfaces/entity";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {filterBy, mapBy, redirect, serialize} from "@/utils";
|
||||
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import {getUserName, isAdmin} from "@/utils/users";
|
||||
import {getEntitiesUsers, getLinkedUsers} from "@/utils/users.be";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { getUserName, isAdmin } from "@/utils/users";
|
||||
import { getEntitiesUsers } from "@/utils/users.be";
|
||||
import axios from "axios";
|
||||
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 Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Divider} from "primereact/divider";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { useRouter } from "next/router";
|
||||
import { Divider } from "primereact/divider";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BsCheck,
|
||||
BsChevronLeft,
|
||||
BsClockFill,
|
||||
BsEnvelopeFill,
|
||||
BsStopwatchFill,
|
||||
} from "react-icons/bs";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { findAllowedEntities } from "@/utils/permissions";
|
||||
import { capitalize } from "lodash";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res);
|
||||
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 users = await getEntitiesUsers(mapBy(entities, 'id'))
|
||||
const allowedEntities = findAllowedEntities(user, entities, "create_classroom")
|
||||
const entities = await getEntitiesWithRoles(
|
||||
isAdmin(user) ? undefined : mapBy(user.entities, "id")
|
||||
);
|
||||
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 {
|
||||
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}),
|
||||
};
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
entities: allowedEntities,
|
||||
users: users,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
entities: EntityWithRoles[];
|
||||
user: User;
|
||||
users: User[];
|
||||
entities: EntityWithRoles[];
|
||||
}
|
||||
|
||||
export default function Home({user, users, entities}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
|
||||
export default function Home({ user, users, entities }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
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(
|
||||
() =>
|
||||
!entity
|
||||
? users
|
||||
: users.filter((u) => mapBy(u.entities, "id").includes(entity)),
|
||||
[entity, users]
|
||||
);
|
||||
|
||||
const {rows, renderSearch} = useListSearch<User>(
|
||||
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers
|
||||
);
|
||||
const { rows, renderSearch } = useListSearch<User>(
|
||||
[
|
||||
["name"],
|
||||
["type"],
|
||||
["corporateInformation", "companyInformation", "name"],
|
||||
],
|
||||
entityUsers
|
||||
);
|
||||
|
||||
const {items, renderMinimal} = usePagination<User>(rows, 16);
|
||||
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => setSelectedUsers([]), [entity])
|
||||
useEffect(() => setSelectedUsers([]), [entity]);
|
||||
|
||||
const createGroup = () => {
|
||||
if (!name.trim()) return;
|
||||
if (!entity) return;
|
||||
if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return;
|
||||
const createGroup = () => {
|
||||
if (!name.trim()) return;
|
||||
if (!entity) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to create this group with ${selectedUsers.length} participants?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity})
|
||||
.then((result) => {
|
||||
toast.success("Your group has been created successfully!");
|
||||
router.replace(`/classrooms/${result.data.id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
axios
|
||||
.post<{ id: string }>(`/api/groups`, {
|
||||
name,
|
||||
participants: selectedUsers,
|
||||
admin: user.id,
|
||||
entity,
|
||||
})
|
||||
.then((result) => {
|
||||
toast.success("Your group has been created successfully!");
|
||||
router.replace(`/classrooms/${result.data.id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Group | 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 />
|
||||
<Layout user={user}>
|
||||
<section className="flex flex-col gap-0">
|
||||
<div className="flex gap-3 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/classrooms"
|
||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">Create Classroom</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={createGroup}
|
||||
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">
|
||||
<BsCheck />
|
||||
<span className="text-xs">Create Classroom</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="grid grid-cols-2 gap-4 place-items-end">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Classroom Name:</span>
|
||||
<Input name="name" onChange={setName} type="text" placeholder="Classroom A" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Entity:</span>
|
||||
<Select
|
||||
options={entities.map((e) => ({value: e.id, label: e.label}))}
|
||||
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
||||
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-semibold text-xl">Participants ({selectedUsers.length} selected):</span>
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderSearch()}
|
||||
{renderMinimal()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{['student', 'teacher', 'corporate'].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => {
|
||||
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id')
|
||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
|
||||
} else {
|
||||
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
|
||||
}
|
||||
}}
|
||||
disabled={filterBy(entityUsers, 'type', type).length === 0}
|
||||
className={clsx(
|
||||
"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",
|
||||
"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).every((u) => selectedUsers.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white",
|
||||
)}>
|
||||
{capitalize(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Group | 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 gap-0">
|
||||
<div className="flex gap-3 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/classrooms"
|
||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||
>
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">Create Classroom</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={createGroup}
|
||||
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"
|
||||
>
|
||||
<BsCheck />
|
||||
<span className="text-xs">Create Classroom</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="grid grid-cols-2 gap-4 place-items-end">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Classroom Name:</span>
|
||||
<Input
|
||||
name="name"
|
||||
onChange={setName}
|
||||
type="text"
|
||||
placeholder="Classroom A"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Entity:</span>
|
||||
<Select
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
||||
defaultValue={{
|
||||
value: entities[0]?.id,
|
||||
label: entities[0]?.label,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-semibold text-xl">
|
||||
Participants ({selectedUsers.length} selected):
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderSearch()}
|
||||
{renderMinimal()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{["student", "teacher", "corporate"].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => {
|
||||
const typeUsers = mapBy(
|
||||
filterBy(entityUsers, "type", type),
|
||||
"id"
|
||||
);
|
||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.filter((a) => !typeUsers.includes(a))
|
||||
);
|
||||
} else {
|
||||
setSelectedUsers((prev) => [
|
||||
...prev.filter((a) => !typeUsers.includes(a)),
|
||||
...typeUsers,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
disabled={filterBy(entityUsers, "type", type).length === 0}
|
||||
className={clsx(
|
||||
"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",
|
||||
"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).every((u) =>
|
||||
selectedUsers.includes(u.id)
|
||||
) &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{capitalize(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{items.map((u) => (
|
||||
<button
|
||||
onClick={() => toggleUser(u)}
|
||||
disabled={isLoading}
|
||||
key={u.id}
|
||||
className={clsx(
|
||||
"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",
|
||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
||||
)}>
|
||||
<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">
|
||||
<img src={u.profilePicture} alt={u.name} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{getUserName(u)}</span>
|
||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{items.map((u) => (
|
||||
<button
|
||||
onClick={() => toggleUser(u)}
|
||||
disabled={isLoading}
|
||||
key={u.id}
|
||||
className={clsx(
|
||||
"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",
|
||||
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<img src={u.profilePicture} alt={u.name} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{getUserName(u)}</span>
|
||||
<span className="opacity-80 text-sm">
|
||||
{USER_TYPE_LABELS[u.type]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="E-mail address">
|
||||
<BsEnvelopeFill />
|
||||
</Tooltip>
|
||||
{u.email}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Expiration Date">
|
||||
<BsStopwatchFill />
|
||||
</Tooltip>
|
||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Last Login">
|
||||
<BsClockFill />
|
||||
</Tooltip>
|
||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="E-mail address">
|
||||
<BsEnvelopeFill />
|
||||
</Tooltip>
|
||||
{u.email}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Expiration Date">
|
||||
<BsStopwatchFill />
|
||||
</Tooltip>
|
||||
{u.subscriptionExpirationDate
|
||||
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
|
||||
: "Unlimited"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Last Login">
|
||||
<BsClockFill />
|
||||
</Tooltip>
|
||||
{u.lastLogin
|
||||
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import { GroupWithUsers, User } from "@/interfaces/user";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { getUserName, isAdmin } from "@/utils/users";
|
||||
@@ -11,13 +10,13 @@ import { convertToUsers, getGroupsForEntities } from "@/utils/groups.be";
|
||||
import { getSpecificUsers } from "@/utils/users.be";
|
||||
import Link from "next/link";
|
||||
import { uniq } from "lodash";
|
||||
import { BsFillMortarboardFill, BsPlus } from "react-icons/bs";
|
||||
import { BsPlus } from "react-icons/bs";
|
||||
import CardList from "@/components/High/CardList";
|
||||
import Separator from "@/components/Low/Separator";
|
||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { findAllowedEntities } from "@/utils/permissions";
|
||||
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { FaPersonChalkboard } from "react-icons/fa6";
|
||||
@@ -28,132 +27,182 @@ import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTrans
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/")
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
const entityIDS = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS)
|
||||
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms")
|
||||
const entityIDS = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(
|
||||
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 groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
|
||||
const groups = await getGroupsForEntities(mapBy(allowedEntities, "id"));
|
||||
|
||||
return {
|
||||
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }),
|
||||
};
|
||||
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 {
|
||||
props: serialize({
|
||||
user,
|
||||
groups: groupsWithUsers,
|
||||
entities: allowedEntities,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
const SEARCH_FIELDS = [
|
||||
["name"],
|
||||
["admin", "name"],
|
||||
["admin", "email"],
|
||||
["admin", "corporateInformation", "companyInformation", "name"],
|
||||
["participants", "name"],
|
||||
["participants", "email"],
|
||||
["participants", "corporateInformation", "companyInformation", "name"],
|
||||
["name"],
|
||||
["admin", "name"],
|
||||
["admin", "email"],
|
||||
["admin", "corporateInformation", "companyInformation", "name"],
|
||||
["participants", "name"],
|
||||
["participants", "email"],
|
||||
["participants", "corporateInformation", "companyInformation", "name"],
|
||||
];
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
groups: GroupWithUsers[];
|
||||
entities: EntityWithRoles[]
|
||||
user: User;
|
||||
groups: GroupWithUsers[];
|
||||
entities: EntityWithRoles[];
|
||||
}
|
||||
export default function Home({ user, groups, entities }: Props) {
|
||||
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom');
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const entitiesAllowCreate = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"create_classroom"
|
||||
);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
const renderCard = (group: GroupWithUsers) => (
|
||||
<Link
|
||||
href={`/classrooms/${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">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">Classroom</span>
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span>
|
||||
{getUserName(group.admin)}
|
||||
</span>
|
||||
{!!group.entity && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span>
|
||||
{findBy(entities, 'id', group.entity)?.label}
|
||||
</span>
|
||||
)}
|
||||
<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-light/50 px-2">{group.participants.length}</span>
|
||||
</span>
|
||||
<span>
|
||||
{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> : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-fit">
|
||||
<FaPersonChalkboard className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
const renderCard = (group: GroupWithUsers) => (
|
||||
<Link
|
||||
href={`/classrooms/${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"
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||
Classroom
|
||||
</span>
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||
Admin
|
||||
</span>
|
||||
{getUserName(group.admin)}
|
||||
</span>
|
||||
{!!group.entity && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||
Entity
|
||||
</span>
|
||||
{findBy(entities, "id", group.entity)?.label}
|
||||
</span>
|
||||
)}
|
||||
<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-light/50 px-2">
|
||||
{group.participants.length}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{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>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-fit">
|
||||
<FaPersonChalkboard className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const firstCard = () => (
|
||||
<Link
|
||||
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">
|
||||
<BsPlus size={40} />
|
||||
<span className="font-semibold">Create Classroom</span>
|
||||
</Link>
|
||||
);
|
||||
const firstCard = () => (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<BsPlus size={40} />
|
||||
<span className="font-semibold">Create Classroom</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Classrooms | 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 />
|
||||
<Layout user={user} className="!gap-4">
|
||||
<section className="flex flex-col gap-4 w-full h-full">
|
||||
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]">
|
||||
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
|
||||
</Modal>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="font-bold text-2xl">Classrooms</h2>
|
||||
{entitiesAllowCreate.length !== 0 && <button
|
||||
className={clsx(
|
||||
"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",
|
||||
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out",
|
||||
)}
|
||||
onClick={() => setShowImport(true)}
|
||||
>
|
||||
<FaFileUpload className="w-5 h-5" />
|
||||
Transfer Students
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Classrooms | 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 gap-4 w-full h-full">
|
||||
<Modal
|
||||
isOpen={showImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
maxWidth="max-w-[85%]"
|
||||
>
|
||||
<StudentClassroomTransfer
|
||||
user={user}
|
||||
entities={entities}
|
||||
onFinish={() => setShowImport(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="font-bold text-2xl">Classrooms</h2>
|
||||
{entitiesAllowCreate.length !== 0 && (
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out"
|
||||
)}
|
||||
onClick={() => setShowImport(true)}
|
||||
>
|
||||
<FaFileUpload className="w-5 h-5" />
|
||||
Transfer Students
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<CardList<GroupWithUsers>
|
||||
list={groups}
|
||||
searchFields={SEARCH_FIELDS}
|
||||
renderCard={renderCard}
|
||||
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
|
||||
/>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<CardList<GroupWithUsers>
|
||||
list={groups}
|
||||
searchFields={SEARCH_FIELDS}
|
||||
renderCard={renderCard}
|
||||
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,191 +1,213 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import UserDisplayList from "@/components/UserDisplayList";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Group, Stat, Type, User } from "@/interfaces/user";
|
||||
import { Stat, Type, User } from "@/interfaces/user";
|
||||
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 { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { countGroups, getGroups } from "@/utils/groups.be";
|
||||
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { getEntities } from "@/utils/entities.be";
|
||||
import { countGroups } from "@/utils/groups.be";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
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 { uniqBy } from "lodash";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BsBank,
|
||||
BsClipboard2Data,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsBank,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
} from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
students: User[];
|
||||
latestStudents: User[]
|
||||
latestTeachers: User[]
|
||||
entities: EntityWithRoles[];
|
||||
usersCount: { [key in Type]: number }
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
user: User;
|
||||
students: User[];
|
||||
latestStudents: User[];
|
||||
latestTeachers: User[];
|
||||
entities: EntityWithRoles[];
|
||||
usersCount: { [key in Type]: number };
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user || !user.isVerified) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
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' });
|
||||
const usersCount = {
|
||||
student: await countUsers({ type: "student" }),
|
||||
teacher: await countUsers({ type: "teacher" }),
|
||||
corporate: await countUsers({ type: "corporate" }),
|
||||
mastercorporate: await countUsers({ type: "mastercorporate" }),
|
||||
}
|
||||
|
||||
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 })
|
||||
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 })
|
||||
|
||||
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 }) };
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
usersCount,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
stats,
|
||||
groupsCount,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
export default function Dashboard({
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
usersCount,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
stats,
|
||||
groupsCount
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
usersCount,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
stats,
|
||||
groupsCount,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user}>
|
||||
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={usersCount.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
label="Corporates"
|
||||
value={usersCount.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=mastercorporate")}
|
||||
label="Master Corporates"
|
||||
value={usersCount.mastercorporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
onClick={() => router.push("/classrooms")}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={usersCount.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
label="Corporates"
|
||||
value={usersCount.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=mastercorporate")}
|
||||
label="Master Corporates"
|
||||
value={usersCount.mastercorporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
onClick={() => router.push("/classrooms")}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<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.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
|
||||
title="Highest level students"
|
||||
/>
|
||||
<UserDisplayList
|
||||
users={
|
||||
students
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
|
||||
)
|
||||
}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<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
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length
|
||||
)}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,194 +1,266 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import UserDisplayList from "@/components/UserDisplayList";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Stat, StudentUser, Type, User } from "@/interfaces/user";
|
||||
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 { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { countGroupsByEntities } from "@/utils/groups.be";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import { calculateAverageLevel } from "@/utils/score";
|
||||
import {
|
||||
checkAccess,
|
||||
groupAllowedEntitiesByPermissions,
|
||||
} from "@/utils/permissions";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
import { getStatsByUsers } from "@/utils/stats.be";
|
||||
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
|
||||
import { countAllowedUsers, getUsers } from "@/utils/users.be";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BsClipboard2Data,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
} from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
students: StudentUser[]
|
||||
latestStudents: User[]
|
||||
latestTeachers: User[]
|
||||
userCounts: { [key in Type]: number }
|
||||
entities: EntityWithRoles[];
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
user: User;
|
||||
students: StudentUser[];
|
||||
latestStudents: User[];
|
||||
latestTeachers: User[];
|
||||
userCounts: { [key in Type]: number };
|
||||
entities: EntityWithRoles[];
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user || !user.isVerified) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
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 entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
const entities = await getEntitiesWithRoles(
|
||||
isAdmin(user) ? undefined : entityIDS
|
||||
);
|
||||
|
||||
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students")
|
||||
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
|
||||
const {
|
||||
["view_students"]: allowedStudentEntities,
|
||||
["view_teachers"]: allowedTeacherEntities,
|
||||
} = groupAllowedEntitiesByPermissions(user, entities, [
|
||||
"view_students",
|
||||
"view_teachers",
|
||||
]);
|
||||
|
||||
const students =
|
||||
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 allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
||||
const entitiesIDS = mapBy(entities, "id") || [];
|
||||
|
||||
const userCounts = await countAllowedUsers(user, entities)
|
||||
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
|
||||
const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
|
||||
const [
|
||||
students,
|
||||
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);
|
||||
|
||||
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) {
|
||||
const totalCount = useMemo(() =>
|
||||
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts])
|
||||
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities])
|
||||
export default function Dashboard({
|
||||
user,
|
||||
students,
|
||||
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 allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
|
||||
const totalLicenses = useMemo(
|
||||
() =>
|
||||
entities.reduce(
|
||||
(acc, curr) => acc + parseInt(curr.licenses.toString()),
|
||||
0
|
||||
),
|
||||
[entities]
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const allowedEntityStatistics = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_entity_statistics"
|
||||
);
|
||||
const allowedStudentPerformance = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_student_performance"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user}>
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{entities.length > 0 && (
|
||||
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
||||
</div>
|
||||
)}
|
||||
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={userCounts.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/classrooms")}
|
||||
Icon={BsPeople}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
className="col-span-2"
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
const router = useRouter();
|
||||
|
||||
<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
|
||||
users={
|
||||
students
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
|
||||
)
|
||||
}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{entities.length > 0 && (
|
||||
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
||||
</div>
|
||||
)}
|
||||
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={userCounts.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/classrooms")}
|
||||
Icon={BsPeople}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={
|
||||
user.subscriptionExpirationDate
|
||||
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
|
||||
: "Unlimited"
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
className="col-span-2"
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length
|
||||
)}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,189 +1,205 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import UserDisplayList from "@/components/UserDisplayList";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
|
||||
import { Stat, Type, User } from "@/interfaces/user";
|
||||
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 { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { countGroups, getGroups } from "@/utils/groups.be";
|
||||
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { getEntities } from "@/utils/entities.be";
|
||||
import { countGroups } from "@/utils/groups.be";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
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 { uniqBy } from "lodash";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BsBank,
|
||||
BsClipboard2Data,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsBank,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
} from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
students: User[];
|
||||
latestStudents: User[]
|
||||
latestTeachers: User[]
|
||||
entities: EntityWithRoles[];
|
||||
usersCount: { [key in Type]: number }
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
user: User;
|
||||
students: User[];
|
||||
latestStudents: User[];
|
||||
latestTeachers: User[];
|
||||
entities: EntityWithRoles[];
|
||||
usersCount: { [key in Type]: number };
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user || !user.isVerified) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
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 usersCount = {
|
||||
student: await countUsers({ type: "student" }),
|
||||
teacher: await countUsers({ type: "teacher" }),
|
||||
corporate: await countUsers({ type: "corporate" }),
|
||||
mastercorporate: await countUsers({ type: "mastercorporate" }),
|
||||
}
|
||||
const [
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
usersCount,
|
||||
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 latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 })
|
||||
const assignmentsCount = await countEntitiesAssignments(
|
||||
mapBy(entities, "id"),
|
||||
{ archived: { $ne: true } }
|
||||
);
|
||||
|
||||
const entities = await getEntitiesWithRoles();
|
||||
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } });
|
||||
const groupsCount = await countGroups();
|
||||
|
||||
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, groupsCount }) };
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
students,
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
usersCount,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
groupsCount,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
export default function Dashboard({
|
||||
user,
|
||||
students = [],
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
usersCount,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
stats = [],
|
||||
groupsCount
|
||||
user,
|
||||
students = [],
|
||||
latestStudents,
|
||||
latestTeachers,
|
||||
usersCount,
|
||||
entities,
|
||||
assignmentsCount,
|
||||
stats = [],
|
||||
groupsCount,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user}>
|
||||
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={usersCount.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
label="Corporates"
|
||||
value={usersCount.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=mastercorporate")}
|
||||
label="Master Corporates"
|
||||
value={usersCount.mastercorporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
onClick={() => router.push("/classrooms")}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={usersCount.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
label="Corporates"
|
||||
value={usersCount.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
onClick={() => router.push("/users?type=mastercorporate")}
|
||||
label="Master Corporates"
|
||||
value={usersCount.mastercorporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
onClick={() => router.push("/classrooms")}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={entities.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={usersCount.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<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
|
||||
users={
|
||||
students
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
|
||||
)
|
||||
}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<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
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b.id))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a.id))).length
|
||||
)}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { redirect } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
|
||||
@@ -1,203 +1,269 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import UserDisplayList from "@/components/UserDisplayList";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import { Module } from "@/interfaces";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
|
||||
import { Stat, StudentUser, Type, User } from "@/interfaces/user";
|
||||
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 { countEntitiesAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { countGroupsByEntities, getGroupsByEntities } from "@/utils/groups.be";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||
import { countGroupsByEntities } from "@/utils/groups.be";
|
||||
import {
|
||||
checkAccess,
|
||||
groupAllowedEntitiesByPermissions,
|
||||
} from "@/utils/permissions";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
import { getStatsByUsers } from "@/utils/stats.be";
|
||||
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
|
||||
import { getEntitiesUsers } from "@/utils/users.be";
|
||||
import { countAllowedUsers, getUsers } from "@/utils/users.be";
|
||||
import { clsx } from "clsx";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BsBank,
|
||||
BsClipboard2Data,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsPaperclip,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsBank,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsPencilSquare,
|
||||
BsPeople,
|
||||
BsPeopleFill,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
} from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
students: StudentUser[]
|
||||
latestStudents: User[]
|
||||
latestTeachers: User[]
|
||||
userCounts: { [key in Type]: number }
|
||||
entities: EntityWithRoles[];
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
user: User;
|
||||
students: StudentUser[];
|
||||
latestStudents: User[];
|
||||
latestTeachers: User[];
|
||||
userCounts: { [key in Type]: number };
|
||||
entities: EntityWithRoles[];
|
||||
assignmentsCount: number;
|
||||
stats: Stat[];
|
||||
groupsCount: number;
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user || !user.isVerified) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
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 entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
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 allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
|
||||
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
||||
|
||||
const students =
|
||||
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 entitiesIDS = mapBy(entities, "id") || [];
|
||||
|
||||
const userCounts = await countAllowedUsers(user, entities)
|
||||
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
|
||||
const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
|
||||
const [
|
||||
students,
|
||||
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);
|
||||
|
||||
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(
|
||||
() =>
|
||||
userCounts.corporate +
|
||||
userCounts.mastercorporate +
|
||||
userCounts.student +
|
||||
userCounts.teacher,
|
||||
[userCounts]
|
||||
);
|
||||
|
||||
const totalCount = useMemo(() =>
|
||||
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 allowedStudentPerformance = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_student_performance"
|
||||
);
|
||||
|
||||
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
|
||||
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={userCounts.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate Accounts"
|
||||
value={userCounts.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
onClick={() => router.push("/classrooms")}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
className={clsx(
|
||||
allowedEntityStatistics.length === 0 && "col-span-2"
|
||||
)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={
|
||||
user.subscriptionExpirationDate
|
||||
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
|
||||
: "Unlimited"
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user}>
|
||||
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=teacher")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={userCounts.teacher}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/users?type=corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate Accounts"
|
||||
value={userCounts.corporate}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
onClick={() => router.push("/classrooms")}
|
||||
label="Classrooms"
|
||||
value={groupsCount}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPeopleFill}
|
||||
onClick={() => router.push("/entities")}
|
||||
label="Entities"
|
||||
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={userCounts.student}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignmentsCount}
|
||||
className={clsx(allowedEntityStatistics.length === 0 && "col-span-2")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<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
|
||||
users={
|
||||
students
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
|
||||
)
|
||||
}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<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
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length
|
||||
)}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
|
||||
import ModuleBadge from "@/components/ModuleBadge";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import { Grading } from "@/interfaces";
|
||||
import { Grading, Module } from "@/interfaces";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { InviteWithEntity } from "@/interfaces/invite";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import { Assignment, AssignmentWithHasResults } from "@/interfaces/results";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { activeAssignmentFilter } from "@/utils/assignments";
|
||||
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { getAssignmentsForStudent } from "@/utils/assignments.be";
|
||||
import { getExamsByIds } from "@/utils/exams.be";
|
||||
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
||||
import { convertInvitersToEntity, getInvitesByInvitee } from "@/utils/invites.be";
|
||||
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils";
|
||||
import {
|
||||
convertInvitersToEntity,
|
||||
getInvitesByInvitee,
|
||||
} from "@/utils/invites.be";
|
||||
import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import { getGradingLabel } from "@/utils/score";
|
||||
import { getSessionsByUser } from "@/utils/sessions.be";
|
||||
import { averageScore } from "@/utils/stats";
|
||||
import { getStatsByUser } from "@/utils/stats.be";
|
||||
import { getDetailedStatsByUser } from "@/utils/stats.be";
|
||||
import clsx from "clsx";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
@@ -35,242 +34,353 @@ import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
assignments: Assignment[];
|
||||
stats: Stat[];
|
||||
exams: Exam[];
|
||||
sessions: Session[];
|
||||
invites: InviteWithEntity[];
|
||||
grading: Grading;
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
assignments: AssignmentWithHasResults[];
|
||||
stats: { fullExams: number; uniqueModules: number; averageScore: number };
|
||||
exams: Exam[];
|
||||
sessions: Session[];
|
||||
invites: InviteWithEntity[];
|
||||
grading: Grading;
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user || !user.isVerified) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
if (!user || !user.isVerified) return redirect("/login");
|
||||
|
||||
if (!checkAccess(user, ["admin", "developer", "student"]))
|
||||
return redirect("/")
|
||||
if (!checkAccess(user, ["admin", "developer", "student"]))
|
||||
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 = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } });
|
||||
const stats = await getStatsByUser(user.id);
|
||||
const sessions = await getSessionsByUser(user.id, 10);
|
||||
const invites = await getInvitesByInvitee(user.id);
|
||||
const grading = await getGradingSystemByEntity(entityIDS[0] || "");
|
||||
const [assignments, stats, invites, grading] = await Promise.all([
|
||||
getAssignmentsForStudent(user.id, currentDate),
|
||||
getDetailedStatsByUser(user.id, "stats"),
|
||||
getInvitesByInvitee(user.id),
|
||||
getGradingSystemByEntity(entityIDS[0] || "", {
|
||||
_id: 0,
|
||||
steps: 1,
|
||||
}),
|
||||
]);
|
||||
const assignmentsIDs = mapBy(assignments, "id");
|
||||
const [sessions, ...formattedInvites] = await Promise.all([
|
||||
getSessionsByUser(user.id, 10, {
|
||||
["assignment.id"]: { $in: assignmentsIDs },
|
||||
}),
|
||||
...invites.map(convertInvitersToEntity),
|
||||
]);
|
||||
|
||||
const formattedInvites = await Promise.all(invites.map(convertInvitersToEntity));
|
||||
const examIDs = uniqBy(
|
||||
assignments.reduce<{ module: Module; id: string; key: string }[]>(
|
||||
(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"
|
||||
);
|
||||
|
||||
const examIDs = uniqBy(
|
||||
assignments.flatMap((a) =>
|
||||
a.exams.filter((e) => e.assignee === user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })),
|
||||
),
|
||||
"key",
|
||||
);
|
||||
const exams = await getExamsByIds(examIDs);
|
||||
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
|
||||
|
||||
return { props: serialize({ user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading }) };
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
assignments,
|
||||
stats: stats ,
|
||||
exams,
|
||||
sessions,
|
||||
invites: formattedInvites,
|
||||
grading,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
export default function Dashboard({ user, entities, assignments, stats, invites, grading, sessions, exams }: Props) {
|
||||
const router = useRouter();
|
||||
export default function Dashboard({
|
||||
user,
|
||||
entities,
|
||||
assignments,
|
||||
stats,
|
||||
invites,
|
||||
grading,
|
||||
sessions,
|
||||
exams,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const assignmentExams = exams.filter(e => {
|
||||
const exam = findBy(assignment.exams, 'id', e.id)
|
||||
return !!exam && exam.module === e.module
|
||||
})
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const assignmentExams = exams.filter((e) => {
|
||||
const exam = findBy(assignment.exams, "id", e.id);
|
||||
return !!exam && exam.module === e.module;
|
||||
});
|
||||
|
||||
if (assignmentExams.every((x) => !!x)) {
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: assignmentExams.sort(sortByModule),
|
||||
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
||||
assignment
|
||||
}
|
||||
})
|
||||
if (assignmentExams.every((x) => !!x)) {
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: {
|
||||
exams: assignmentExams.sort(sortByModule),
|
||||
modules: mapBy(assignmentExams.sort(sortByModule), "module"),
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user}>
|
||||
{entities.length > 0 && (
|
||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<>
|
||||
{entities.length > 0 && (
|
||||
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
|
||||
<b>{entitiesLabels}</b>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: countFullExams(stats),
|
||||
label: "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" />,
|
||||
value: countExamModules(stats),
|
||||
label: "Modules",
|
||||
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" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
tooltip: "Average success rate for questions responded",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: (
|
||||
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
||||
),
|
||||
value: stats?.fullExams || 0,
|
||||
label: "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" />
|
||||
),
|
||||
value: stats?.uniqueModules || 0,
|
||||
label: "Modules",
|
||||
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" />
|
||||
),
|
||||
value: `${stats?.averageScore.toFixed(2) || 0}%`,
|
||||
label: "Average Score",
|
||||
tooltip: "Average success rate for questions responded",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Assignments */}
|
||||
<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-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
|
||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
||||
.map((assignment) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"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",
|
||||
)}
|
||||
key={assignment.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||
<span className="flex justify-between gap-1 text-lg">
|
||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
.sort(sortByModuleName)
|
||||
.map((module) => (
|
||||
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
||||
))}
|
||||
</div>
|
||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
<>
|
||||
<div
|
||||
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">
|
||||
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
data-tip="You have already started this assignment!"
|
||||
className={clsx(
|
||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
|
||||
)}>
|
||||
<Button
|
||||
className={clsx("w-full h-full !rounded-xl")}
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline"
|
||||
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
<Button
|
||||
onClick={() => router.push("/record")}
|
||||
color="green"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
variant="outline">
|
||||
Submitted
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
{/* Assignments */}
|
||||
<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-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{assignments.length === 0 &&
|
||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||
{assignments.map((assignment) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
||||
assignment.hasResults && "border-mti-green-light"
|
||||
)}
|
||||
key={assignment.id}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-mti-black/90 text-xl font-semibold">
|
||||
{assignment.name}
|
||||
</h3>
|
||||
<span className="flex justify-between gap-1 text-lg">
|
||||
<span>
|
||||
{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
<span>-</span>
|
||||
<span>
|
||||
{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{assignment.exams.map((e) => (
|
||||
<ModuleBadge
|
||||
className="scale-110 w-full"
|
||||
key={e.module}
|
||||
module={e.module}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!assignment.hasResults && (
|
||||
<>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<Button
|
||||
className="h-full w-full !rounded-xl"
|
||||
variant="outline"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
data-tip="You have already started this assignment!"
|
||||
className={clsx(
|
||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||
sessions.filter(
|
||||
(x) => x.assignment?.id === assignment.id
|
||||
).length > 0 && "tooltip"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className={clsx("w-full h-full !rounded-xl")}
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline"
|
||||
disabled={
|
||||
sessions.filter(
|
||||
(x) => x.assignment?.id === assignment.id
|
||||
).length > 0
|
||||
}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{assignment.hasResults && (
|
||||
<Button
|
||||
onClick={() => router.push("/record")}
|
||||
color="green"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
variant="outline"
|
||||
>
|
||||
Submitted
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{/* Invites */}
|
||||
{invites.length > 0 && (
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{invites.map((invite) => (
|
||||
<InviteWithUserCard key={invite.id} invite={invite} reload={() => router.replace(router.asPath)} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
{/* Invites */}
|
||||
{invites.length > 0 && (
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{invites.map((invite) => (
|
||||
<InviteWithUserCard
|
||||
key={invite.id}
|
||||
invite={invite}
|
||||
reload={() => router.replace(router.asPath)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Score History */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">Score History</span>
|
||||
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
||||
{MODULE_ARRAY.map((module) => {
|
||||
const desiredLevel = user.desiredLevels[module] || 9;
|
||||
const level = user.levels[module] || 0;
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
||||
<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">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "listening" && <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 className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||
<span className="text-mti-gray-dim text-sm font-normal">
|
||||
{module === "level" && !!grading && `English Level: ${getGradingLabel(level, grading.steps)}`}
|
||||
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:pl-14">
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={module === "level" ? level : Math.round((level * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
{/* Score History */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">Score History</span>
|
||||
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
||||
{MODULE_ARRAY.map((module) => {
|
||||
const desiredLevel = user.desiredLevels[module] || 9;
|
||||
const level = user.levels[module] || 0;
|
||||
return (
|
||||
<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="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 === "listening" && (
|
||||
<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 className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">
|
||||
{capitalize(module)}
|
||||
</span>
|
||||
<span className="text-mti-gray-dim text-sm font-normal">
|
||||
{module === "level" &&
|
||||
!!grading &&
|
||||
`English Level: ${getGradingLabel(
|
||||
level,
|
||||
grading.steps
|
||||
)}`}
|
||||
{module !== "level" &&
|
||||
`Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:pl-14">
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={
|
||||
module === "level"
|
||||
? undefined
|
||||
: Math.round((desiredLevel * 100) / 9)
|
||||
}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={
|
||||
module === "level"
|
||||
? level
|
||||
: Math.round((level * 100) / 9)
|
||||
}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import UserDisplayList from "@/components/UserDisplayList";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import { Module } from "@/interfaces";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Group, Stat, User } from "@/interfaces/user";
|
||||
@@ -12,138 +10,189 @@ import { getEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { getGroupsByEntities } from "@/utils/groups.be";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||
import { calculateAverageLevel } from "@/utils/score";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
import { getStatsByUsers } from "@/utils/stats.be";
|
||||
import { getEntitiesUsers } from "@/utils/users.be";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { uniqBy } from "lodash";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import { BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill, BsPersonFillGear } from "react-icons/bs";
|
||||
import {
|
||||
BsEnvelopePaper,
|
||||
BsPeople,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
} from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import { filterAllowedUsers } from "@/utils/users.be";
|
||||
import { getEntitiesUsers } from "@/utils/users.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
entities: EntityWithRoles[];
|
||||
assignments: Assignment[];
|
||||
stats: Stat[];
|
||||
groups: Group[];
|
||||
user: User;
|
||||
students: User[];
|
||||
entities: EntityWithRoles[];
|
||||
assignments: Assignment[];
|
||||
stats: Stat[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user || !user.isVerified) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
if (!user || !user.isVerified) return redirect("/login");
|
||||
|
||||
if (!checkAccess(user, ["admin", "developer", "teacher"]))
|
||||
return redirect("/")
|
||||
if (!checkAccess(user, ["admin", "developer", "teacher"]))
|
||||
return redirect("/");
|
||||
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const users = await filterAllowedUsers(user, entities)
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
|
||||
const assignments = await getEntitiesAssignments(entityIDS);
|
||||
const stats = await getStatsByUsers(users.map((u) => u.id));
|
||||
const groups = await getGroupsByEntities(entityIDS);
|
||||
const entities = await getEntitiesWithRoles(
|
||||
isAdmin(user) ? undefined : 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);
|
||||
|
||||
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
|
||||
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
|
||||
const router = useRouter();
|
||||
export default function Dashboard({
|
||||
user,
|
||||
students,
|
||||
entities,
|
||||
assignments,
|
||||
stats,
|
||||
groups,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
|
||||
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
|
||||
const allowedEntityStatistics = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_entity_statistics"
|
||||
);
|
||||
const allowedStudentPerformance = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_student_performance"
|
||||
);
|
||||
const entitiesLabels = useMemo(
|
||||
() => mapBy(entities, "label")?.join(", "),
|
||||
[entities]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user}>
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{entities.length > 0 && (
|
||||
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
||||
</div>
|
||||
)}
|
||||
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
||||
<IconCard
|
||||
Icon={BsPersonFill}
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
label="Students"
|
||||
value={students.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/classrooms")}
|
||||
Icon={BsPeople}
|
||||
label="Classrooms"
|
||||
value={groups.length}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={students.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignments.filter((a) => !a.archived).length}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{entities.length > 0 && (
|
||||
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
||||
<b>{entitiesLabels}</b>
|
||||
</div>
|
||||
)}
|
||||
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
||||
<IconCard
|
||||
Icon={BsPersonFill}
|
||||
onClick={() => router.push("/users?type=student")}
|
||||
label="Students"
|
||||
value={students.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("/classrooms")}
|
||||
Icon={BsPeople}
|
||||
label="Classrooms"
|
||||
value={groups.length}
|
||||
color="purple"
|
||||
/>
|
||||
{allowedStudentPerformance.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/users/performance")}
|
||||
label="Student Performance"
|
||||
value={students.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{allowedEntityStatistics.length > 0 && (
|
||||
<IconCard
|
||||
Icon={BsPersonFillGear}
|
||||
onClick={() => router.push("/statistical")}
|
||||
label="Entity Statistics"
|
||||
value={allowedEntityStatistics.length}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
<IconCard
|
||||
Icon={BsEnvelopePaper}
|
||||
onClick={() => router.push("/assignments")}
|
||||
label="Assignments"
|
||||
value={assignments.filter((a) => !a.archived).length}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<UserDisplayList
|
||||
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
|
||||
title="Latest Students"
|
||||
/>
|
||||
<UserDisplayList
|
||||
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
|
||||
title="Highest level students"
|
||||
/>
|
||||
<UserDisplayList
|
||||
users={
|
||||
students
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
|
||||
)
|
||||
}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<UserDisplayList
|
||||
users={students.sort((a, b) =>
|
||||
dateSorter(a, b, "desc", "registrationDate")
|
||||
)}
|
||||
title="Latest Students"
|
||||
/>
|
||||
<UserDisplayList
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
calculateAverageLevel(b.levels) -
|
||||
calculateAverageLevel(a.levels)
|
||||
)}
|
||||
title="Highest level students"
|
||||
/>
|
||||
<UserDisplayList
|
||||
users={students.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length
|
||||
)}
|
||||
title="Highest exam count students"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import Separator from "@/components/Low/Separator";
|
||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||
@@ -152,7 +151,7 @@ interface Props {
|
||||
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 [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" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Layout user={user}>
|
||||
<>
|
||||
<section className="flex flex-col gap-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-end justify-between">
|
||||
@@ -388,7 +387,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import CardList from "@/components/High/CardList";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Tooltip from "@/components/Low/Tooltip";
|
||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||
import {useListSearch} from "@/hooks/useListSearch";
|
||||
import usePagination from "@/hooks/usePagination";
|
||||
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
|
||||
import {GroupWithUsers, User} from "@/interfaces/user";
|
||||
import { EntityWithRoles, Role} from "@/interfaces/entity";
|
||||
import { User} from "@/interfaces/user";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import { redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import {getEntityWithRoles} from "@/utils/entities.be";
|
||||
import {convertToUsers, getGroup} from "@/utils/groups.be";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import {checkAccess, doesEntityAllow, getTypesOfUser} from "@/utils/permissions";
|
||||
import {getUserName} from "@/utils/users";
|
||||
import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be";
|
||||
import { doesEntityAllow} from "@/utils/permissions";
|
||||
import {getEntityUsers} from "@/utils/users.be";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Divider} from "primereact/divider";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {
|
||||
BsChevronLeft,
|
||||
BsClockFill,
|
||||
BsEnvelopeFill,
|
||||
BsFillPersonVcardFill,
|
||||
BsPlus,
|
||||
BsSquare,
|
||||
BsStopwatchFill,
|
||||
BsTag,
|
||||
BsTrash,
|
||||
BsX,
|
||||
} from "react-icons/bs";
|
||||
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" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
<Layout user={user}>
|
||||
<>
|
||||
<section className="flex flex-col gap-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<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} />
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Input from "@/components/Low/Input";
|
||||
import Select from "@/components/Low/Select";
|
||||
import Tooltip from "@/components/Low/Tooltip";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import usePagination from "@/hooks/usePagination";
|
||||
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Entity } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { mapBy, redirect, serialize } from "@/utils";
|
||||
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { redirect, serialize } from "@/utils";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { getUserName } from "@/utils/users";
|
||||
import { getLinkedUsers, getUsers } from "@/utils/users.be";
|
||||
import { getUsers } from "@/utils/users.be";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
@@ -23,162 +20,218 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Divider } from "primereact/divider";
|
||||
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 { requestUser } from "@/utils/api";
|
||||
import { findAllowedEntities } from "@/utils/permissions";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/")
|
||||
if (!["admin", "developer"].includes(user.type)) return redirect("/entities")
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
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 {
|
||||
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }),
|
||||
};
|
||||
return {
|
||||
props: serialize({ user, users }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
user: User;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export default function Home({ user, users }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [label, setLabel] = useState("");
|
||||
const [licenses, setLicenses] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [label, setLabel] = useState("");
|
||||
const [licenses, setLicenses] = useState(0);
|
||||
|
||||
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users);
|
||||
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||
const { rows, renderSearch } = useListSearch<User>(
|
||||
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||
users
|
||||
);
|
||||
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const createGroup = () => {
|
||||
if (!label.trim()) return;
|
||||
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return;
|
||||
const createGroup = () => {
|
||||
if (!label.trim()) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers })
|
||||
.then((result) => {
|
||||
toast.success("Your entity has been created successfully!");
|
||||
router.replace(`/entities/${result.data.id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
axios
|
||||
.post<Entity>(`/api/entities`, {
|
||||
label,
|
||||
licenses,
|
||||
members: selectedUsers,
|
||||
})
|
||||
.then((result) => {
|
||||
toast.success("Your entity has been created successfully!");
|
||||
router.replace(`/entities/${result.data.id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Entity | 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 />
|
||||
<Layout user={user}>
|
||||
<section className="flex flex-col gap-0">
|
||||
<div className="flex gap-3 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/classrooms"
|
||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">Create Entity</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={createGroup}
|
||||
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">
|
||||
<BsCheck />
|
||||
<span className="text-xs">Create Entity</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="w-full grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Entity Label:</span>
|
||||
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" />
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Entity | 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 gap-0">
|
||||
<div className="flex gap-3 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/classrooms"
|
||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||
>
|
||||
<BsChevronLeft />
|
||||
</Link>
|
||||
<h2 className="font-bold text-2xl">Create Entity</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={createGroup}
|
||||
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"
|
||||
>
|
||||
<BsCheck />
|
||||
<span className="text-xs">Create Entity</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="w-full grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Entity Label:</span>
|
||||
<Input
|
||||
name="name"
|
||||
onChange={setLabel}
|
||||
type="text"
|
||||
placeholder="Entity A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Licenses:</span>
|
||||
<Input name="licenses" min={0} onChange={(v) => setLicenses(parseInt(v))} type="number" placeholder="12" />
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span>
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderSearch()}
|
||||
{renderMinimal()}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span className="font-semibold text-xl">Licenses:</span>
|
||||
<Input
|
||||
name="licenses"
|
||||
min={0}
|
||||
onChange={(v) => setLicenses(parseInt(v))}
|
||||
type="number"
|
||||
placeholder="12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-semibold text-xl">
|
||||
Members ({selectedUsers.length} selected):
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{renderSearch()}
|
||||
{renderMinimal()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{items.map((u) => (
|
||||
<button
|
||||
onClick={() => toggleUser(u)}
|
||||
disabled={isLoading}
|
||||
key={u.id}
|
||||
className={clsx(
|
||||
"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",
|
||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
||||
)}>
|
||||
<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">
|
||||
<img src={u.profilePicture} alt={u.name} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{getUserName(u)}</span>
|
||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{items.map((u) => (
|
||||
<button
|
||||
onClick={() => toggleUser(u)}
|
||||
disabled={isLoading}
|
||||
key={u.id}
|
||||
className={clsx(
|
||||
"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",
|
||||
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<img src={u.profilePicture} alt={u.name} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{getUserName(u)}</span>
|
||||
<span className="opacity-80 text-sm">
|
||||
{USER_TYPE_LABELS[u.type]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="E-mail address">
|
||||
<BsEnvelopeFill />
|
||||
</Tooltip>
|
||||
{u.email}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Expiration Date">
|
||||
<BsStopwatchFill />
|
||||
</Tooltip>
|
||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Last Login">
|
||||
<BsClockFill />
|
||||
</Tooltip>
|
||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="E-mail address">
|
||||
<BsEnvelopeFill />
|
||||
</Tooltip>
|
||||
{u.email}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Expiration Date">
|
||||
<BsStopwatchFill />
|
||||
</Tooltip>
|
||||
{u.subscriptionExpirationDate
|
||||
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
|
||||
: "Unlimited"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tooltip tooltip="Last Login">
|
||||
<BsClockFill />
|
||||
</Tooltip>
|
||||
{u.lastLogin
|
||||
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,12 @@ import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import { GroupWithUsers, User } from "@/interfaces/user";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { getUserName, isAdmin } from "@/utils/users";
|
||||
import { convertToUsers, getGroupsForUser } from "@/utils/groups.be";
|
||||
import { countEntityUsers, getEntityUsers, getSpecificUsers, getUsers } from "@/utils/users.be";
|
||||
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
|
||||
import { countEntityUsers, getEntityUsers } from "@/utils/users.be";
|
||||
import { findAllowedEntities } from "@/utils/permissions";
|
||||
import Link from "next/link";
|
||||
import { uniq } from "lodash";
|
||||
import { BsBank, BsPlus } from "react-icons/bs";
|
||||
import CardList from "@/components/High/CardList";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
@@ -20,99 +17,144 @@ import Separator from "@/components/Low/Separator";
|
||||
import { requestUser } from "@/utils/api";
|
||||
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 }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/")
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
const entityIDs = mapBy(user.entities, 'id')
|
||||
const entities = await getEntitiesWithRoles(["admin", "developer"].includes(user.type) ? undefined : entityIDs);
|
||||
const allowedEntities = findAllowedEntities(user, entities, 'view_entities')
|
||||
const entityIDs = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(
|
||||
["admin", "developer"].includes(user.type) ? undefined : entityIDs
|
||||
);
|
||||
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
|
||||
|
||||
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 [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 }
|
||||
)
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: serialize({ user, entities: entitiesWithCount }),
|
||||
};
|
||||
const entitiesWithCount = allowedEntities.map<{
|
||||
entity: EntityWithRoles;
|
||||
users: User[];
|
||||
count: number;
|
||||
}>((e, i) => ({ entity: e, users: users[i], count: counts[i] }));
|
||||
|
||||
return {
|
||||
props: serialize({ user, entities: entitiesWithCount }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
const SEARCH_FIELDS: string[][] = [["entity", "label"]];
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
entities: EntitiesWithCount[];
|
||||
user: User;
|
||||
entities: EntitiesWithCount[];
|
||||
}
|
||||
export default function Home({ user, entities }: Props) {
|
||||
const renderCard = ({ entity, users, count }: EntitiesWithCount) => (
|
||||
<Link
|
||||
href={`/entities/${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">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span>
|
||||
{entity.label}
|
||||
</span>
|
||||
<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-light/50 px-2">{count}{isAdmin(user) && ` / ${entity.licenses || 0}`}</span>
|
||||
</span>
|
||||
<span>
|
||||
{users.map(getUserName).join(", ")}{' '}
|
||||
{count > 5 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {count - 5} more</span> : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-fit">
|
||||
<BsBank className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
const renderCard = ({ entity, users, count }: EntitiesWithCount) => (
|
||||
<Link
|
||||
href={`/entities/${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"
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||
Entity
|
||||
</span>
|
||||
{entity.label}
|
||||
</span>
|
||||
<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-light/50 px-2">
|
||||
{count}
|
||||
{isAdmin(user) && ` / ${entity.licenses || 0}`}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{users.map(getUserName).join(", ")}{" "}
|
||||
{count > 5 ? (
|
||||
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
|
||||
and {count - 5} more
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-fit">
|
||||
<BsBank className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const firstCard = () => (
|
||||
<Link
|
||||
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">
|
||||
<BsPlus size={40} />
|
||||
<span className="font-semibold">Create Entity</span>
|
||||
</Link>
|
||||
);
|
||||
const firstCard = () => (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<BsPlus size={40} />
|
||||
<span className="font-semibold">Create Entity</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Entities | 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 />
|
||||
<Layout user={user} className="!gap-4">
|
||||
<section className="flex flex-col gap-4 w-full h-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-bold text-2xl">Entities</h2>
|
||||
<Separator />
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Entities | 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 gap-4 w-full h-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-bold text-2xl">Entities</h2>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<CardList<EntitiesWithCount>
|
||||
list={entities}
|
||||
searchFields={SEARCH_FIELDS}
|
||||
renderCard={renderCard}
|
||||
firstCard={["admin", "developer"].includes(user.type) ? firstCard : undefined}
|
||||
/>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
<CardList<EntitiesWithCount>
|
||||
list={entities}
|
||||
searchFields={SEARCH_FIELDS}
|
||||
renderCard={renderCard}
|
||||
firstCard={
|
||||
["admin", "developer"].includes(user.type) ? firstCard : undefined
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,95 +20,125 @@ import { useRouter } from "next/router";
|
||||
import { getSessionByAssignment } from "@/utils/sessions.be";
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import { activeAssignmentFilter } from "@/utils/assignments";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
||||
const user = await requestUser(req, res)
|
||||
const loginDestination = Buffer.from(req.url || "/").toString("base64")
|
||||
if (!user) return redirect(`/login?destination=${loginDestination}`)
|
||||
export const getServerSideProps = withIronSessionSsr(
|
||||
async ({ req, res, query }) => {
|
||||
const user = await requestUser(req, res);
|
||||
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 destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined
|
||||
const { assignment: assignmentID, destination } = query as {
|
||||
assignment?: string;
|
||||
destination?: string;
|
||||
};
|
||||
const destinationURL = !!destination
|
||||
? Buffer.from(destination, "base64").toString()
|
||||
: undefined;
|
||||
|
||||
if (!!assignmentID) {
|
||||
const assignment = await getAssignment(assignmentID)
|
||||
if (!!assignmentID) {
|
||||
const assignment = await getAssignment(assignmentID);
|
||||
|
||||
if (!assignment) return redirect(destinationURL || "/exam")
|
||||
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type))
|
||||
return redirect(destinationURL || "/exam")
|
||||
if (!assignment) return redirect(destinationURL || "/exam");
|
||||
if (
|
||||
!assignment.assignees.includes(user.id) &&
|
||||
!["admin", "developer"].includes(user.type)
|
||||
)
|
||||
return redirect(destinationURL || "/exam");
|
||||
|
||||
if (filterBy(assignment.results, 'user', user.id).length > 0)
|
||||
return redirect(destinationURL || "/exam")
|
||||
if (filterBy(assignment.results, "user", user.id).length > 0)
|
||||
return redirect(destinationURL || "/exam");
|
||||
|
||||
const [exams, session] = await Promise.all([
|
||||
getExamsByIds(uniqBy(assignment.exams, "id")),
|
||||
getSessionByAssignment(assignmentID),
|
||||
]);
|
||||
|
||||
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
|
||||
const session = await getSessionByAssignment(assignmentID)
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
assignment,
|
||||
exams,
|
||||
destinationURL,
|
||||
session: session ?? undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: serialize({ user, assignment, exams, destinationURL, session: session ?? undefined })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: serialize({ user, destinationURL }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
return {
|
||||
props: serialize({ user, destinationURL }),
|
||||
};
|
||||
},
|
||||
sessionOptions
|
||||
);
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
assignment?: Assignment
|
||||
exams?: Exam[]
|
||||
session?: Session
|
||||
destinationURL?: string
|
||||
user: User;
|
||||
assignment?: Assignment;
|
||||
exams?: Exam[];
|
||||
session?: Session;
|
||||
destinationURL?: string;
|
||||
}
|
||||
|
||||
const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => {
|
||||
const router = useRouter()
|
||||
const { assignment: storeAssignment, dispatch } = useExamStore();
|
||||
const Page: React.FC<Props> = ({
|
||||
user,
|
||||
assignment,
|
||||
exams = [],
|
||||
destinationURL = "/exam",
|
||||
session,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { assignment: storeAssignment, dispatch } = useExamStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||
if (!activeAssignmentFilter(assignment)) return
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: exams.sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
assignment
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||
if (!activeAssignmentFilter(assignment)) return;
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: {
|
||||
exams: exams.sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
assignment,
|
||||
},
|
||||
});
|
||||
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session])
|
||||
router.replace(router.asPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } })
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session])
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||
router.replace(router.asPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exams | 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>
|
||||
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!storeAssignment} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exams | 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>
|
||||
<ExamPage
|
||||
page="exams"
|
||||
destination={destinationURL}
|
||||
user={user}
|
||||
hideSidebar={!!assignment || !!storeAssignment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
//Page.whyDidYouRender = true;
|
||||
export default Page;
|
||||
|
||||
@@ -21,92 +21,100 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import moment from "moment";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
||||
const user = await requestUser(req, res)
|
||||
const destination = Buffer.from(req.url || "/").toString("base64")
|
||||
if (!user) return redirect(`/login?destination=${destination}`)
|
||||
export const getServerSideProps = withIronSessionSsr(
|
||||
async ({ req, res, query }) => {
|
||||
const user = await requestUser(req, res);
|
||||
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) {
|
||||
const assignment = await getAssignment(assignmentID)
|
||||
if (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")
|
||||
if (!assignment) return redirect("/exam");
|
||||
if (
|
||||
!["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.endDate).isAfter(moment())
|
||||
)
|
||||
return redirect("/exam");
|
||||
const [exams, session] = await Promise.all([
|
||||
getExamsByIds(uniqBy(assignment.exams, "id")),
|
||||
getSessionByAssignment(assignmentID),
|
||||
]);
|
||||
|
||||
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
|
||||
const session = await getSessionByAssignment(assignmentID)
|
||||
return {
|
||||
props: serialize({ user, assignment, exams, session }),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
filterBy(assignment.results, 'user', user.id) ||
|
||||
moment(assignment.startDate).isBefore(moment()) ||
|
||||
moment(assignment.endDate).isAfter(moment())
|
||||
)
|
||||
return redirect("/exam")
|
||||
|
||||
return {
|
||||
props: serialize({ user, assignment, exams, session })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: serialize({ user }),
|
||||
};
|
||||
}, sessionOptions);
|
||||
return {
|
||||
props: serialize({ user }),
|
||||
};
|
||||
},
|
||||
sessionOptions
|
||||
);
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
assignment?: Assignment
|
||||
exams?: Exam[]
|
||||
session?: Session
|
||||
user: User;
|
||||
assignment?: Assignment;
|
||||
exams?: Exam[];
|
||||
session?: Session;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: exams.sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
assignment
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: {
|
||||
exams: exams.sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
assignment,
|
||||
},
|
||||
});
|
||||
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session])
|
||||
router.replace(router.asPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session])
|
||||
router.replace(router.asPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exams, session]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exams | 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>
|
||||
<ExamPage page="exams" user={user} hideSidebar={!!assignment} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exams | 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>
|
||||
<ExamPage page="exams" user={user} hideSidebar={!!assignment} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,24 +2,22 @@
|
||||
import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import Input from "@/components/Low/Input";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import { findAllowedEntities } from "@/utils/permissions";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import ExamEditorStore from "@/stores/examEditor/types";
|
||||
import ExamEditor from "@/components/ExamEditor";
|
||||
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
|
||||
import { mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
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 { useEffect, useState } from "react";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
@@ -157,7 +155,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user} className="gap-6">
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold">Exam Editor</h1>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
@@ -175,7 +173,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
||||
<RadioGroup
|
||||
value={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) => (
|
||||
<Radio value={x} key={x}>
|
||||
{({ checked }) => (
|
||||
@@ -212,7 +210,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<ExamEditor levelParts={examLevelParts} />
|
||||
</Layout>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import AssignmentCard from "@/components/High/AssignmentCard";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Button from "@/components/Low/Button";
|
||||
import Separator from "@/components/Low/Separator";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import { Grading } from "@/interfaces";
|
||||
import { Grading, Module } from "@/interfaces";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { InviteWithEntity } from "@/interfaces/invite";
|
||||
@@ -13,11 +12,13 @@ import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
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 { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
|
||||
import {
|
||||
activeAssignmentFilter,
|
||||
futureAssignmentFilter,
|
||||
} from "@/utils/assignments";
|
||||
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { getExamsByIds } from "@/utils/exams.be";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
@@ -26,7 +27,6 @@ import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -34,142 +34,217 @@ import { BsArrowRepeat } from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
assignments: Assignment[];
|
||||
stats: Stat[];
|
||||
exams: Exam[];
|
||||
sessions: Session[];
|
||||
invites: InviteWithEntity[];
|
||||
grading: Grading;
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
assignments: Assignment[];
|
||||
stats: Stat[];
|
||||
exams: Exam[];
|
||||
sessions: Session[];
|
||||
invites: InviteWithEntity[];
|
||||
grading: Grading;
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
const destination = Buffer.from(req.url || "/").toString("base64")
|
||||
if (!user) return redirect(`/login?destination=${destination}`)
|
||||
const user = await requestUser(req, res);
|
||||
const destination = Buffer.from(req.url || "/").toString("base64");
|
||||
if (!user) return redirect(`/login?destination=${destination}`);
|
||||
|
||||
if (!checkAccess(user, ["admin", "developer", "student"]))
|
||||
return redirect("/")
|
||||
if (!checkAccess(user, ["admin", "developer", "student"]))
|
||||
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,
|
||||
0,
|
||||
{
|
||||
"assignment.id": { $in: mapBy(assignments, "id") },
|
||||
},
|
||||
{
|
||||
_id: 0,
|
||||
id: 1,
|
||||
assignment: 1,
|
||||
}
|
||||
);
|
||||
|
||||
const entities = await getEntitiesWithRoles(entityIDS);
|
||||
const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } });
|
||||
const sessions = await getSessionsByUser(user.id, 0, { "assignment.id": { $in: mapBy(assignments, 'id') } });
|
||||
const examIDs = uniqBy(
|
||||
assignments.reduce<{ module: Module; id: string; key: string }[]>(
|
||||
(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"
|
||||
);
|
||||
|
||||
const examIDs = uniqBy(
|
||||
assignments.flatMap((a) =>
|
||||
filterBy(a.exams, 'assignee', user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })),
|
||||
),
|
||||
"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);
|
||||
|
||||
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) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
export default function OfficialExam({
|
||||
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 = () => {
|
||||
setIsLoading(true)
|
||||
router.replace(router.asPath)
|
||||
setTimeout(() => setIsLoading(false), 500)
|
||||
}
|
||||
const reload = () => {
|
||||
setIsLoading(true);
|
||||
router.replace(router.asPath);
|
||||
setTimeout(() => setIsLoading(false), 500);
|
||||
};
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const assignmentExams = exams.filter(e => {
|
||||
const exam = findBy(assignment.exams, 'id', e.id)
|
||||
return !!exam && exam.module === e.module
|
||||
})
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const assignmentExams = exams.filter((e) => {
|
||||
const exam = findBy(assignment.exams, "id", e.id);
|
||||
return !!exam && exam.module === e.module;
|
||||
});
|
||||
|
||||
if (assignmentExams.every((x) => !!x)) {
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: assignmentExams.sort(sortByModule),
|
||||
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
||||
assignment
|
||||
}
|
||||
})
|
||||
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`);
|
||||
}
|
||||
};
|
||||
if (assignmentExams.every((x) => !!x)) {
|
||||
const sortedAssignmentExams = assignmentExams.sort(sortByModule);
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: {
|
||||
exams: sortedAssignmentExams,
|
||||
modules: mapBy(sortedAssignmentExams, "module"),
|
||||
assignment,
|
||||
},
|
||||
});
|
||||
router.push(
|
||||
`/exam?assignment=${assignment.id}&destination=${destination}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
dispatch({type: "SET_SESSION", payload: {session}});
|
||||
router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`);
|
||||
};
|
||||
const loadSession = async (session: Session) => {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||
router.push(
|
||||
`/exam?assignment=${session.assignment?.id}&destination=${destination}`
|
||||
);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const studentAssignments = useMemo(() => [
|
||||
...assignments.filter(activeAssignmentFilter), ...assignments.filter(futureAssignmentFilter)],
|
||||
[assignments]
|
||||
);
|
||||
const studentAssignments = useMemo(
|
||||
() => [
|
||||
...assignments.filter(activeAssignmentFilter),
|
||||
...assignments.filter(futureAssignmentFilter),
|
||||
],
|
||||
[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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user} hideSidebar>
|
||||
{entities.length > 0 && (
|
||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
||||
</div>
|
||||
)}
|
||||
const entityLabels = useMemo(
|
||||
() => mapBy(entities, "label")?.join(","),
|
||||
[entities]
|
||||
);
|
||||
|
||||
<ProfileSummary user={user} items={[]} removeLevel />
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<>
|
||||
{entities.length > 0 && (
|
||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||
<b>{entityLabels}</b>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<ProfileSummary user={user} items={[]} removeLevel />
|
||||
|
||||
{/* Assignments */}
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div
|
||||
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">
|
||||
<span className="text-mti-black text-lg font-bold">Assignments</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||
</div>
|
||||
<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
|
||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
||||
.map((a) =>
|
||||
<AssignmentCard
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
user={user}
|
||||
session={assignmentSessions.find(s => s.assignment?.id === a.id)}
|
||||
startAssignment={startAssignment}
|
||||
resumeAssignment={loadSession}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
<Separator />
|
||||
|
||||
<Button onClick={logout} variant="outline" color="red" className="max-w-[200px] w-full absolute bottom-8 left-8">Sign out</Button>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
{/* Assignments */}
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<span className="text-mti-black text-lg font-bold">
|
||||
Assignments
|
||||
</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||
/>
|
||||
</div>
|
||||
<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.map((a) => (
|
||||
<AssignmentCard
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
user={user}
|
||||
session={assignmentSessions.find(
|
||||
(s) => s.assignment?.id === a.id
|
||||
)}
|
||||
startAssignment={startAssignment}
|
||||
resumeAssignment={loadSession}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<Button
|
||||
onClick={logout}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="max-w-[200px] w-full absolute bottom-8 left-8"
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import usePayments from "@/hooks/usePayments";
|
||||
import usePaypalPayments from "@/hooks/usePaypalPayments";
|
||||
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 { BsTrash } from "react-icons/bs";
|
||||
import axios from "axios";
|
||||
@@ -33,7 +31,7 @@ import { useListSearch } from "@/hooks/useListSearch";
|
||||
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
|
||||
import { requestUser } from "@/utils/api";
|
||||
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 { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
@@ -943,7 +941,7 @@ export default function PaymentRecord({ user, entities }: Props) {
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user} className="gap-6">
|
||||
<>
|
||||
{getUserModal()}
|
||||
<Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}>
|
||||
<PaymentCreator
|
||||
@@ -1248,7 +1246,7 @@ export default function PaymentRecord({ user, entities }: Props) {
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</Layout>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -21,11 +21,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
if (!user) return redirect("/login")
|
||||
|
||||
const entityIDs = mapBy(user.entities, 'id')
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
|
||||
|
||||
const domain = user.email.split("@").pop()
|
||||
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray()
|
||||
const packages = await db.collection<Package>("packages").find().toArray()
|
||||
const [entities, discounts, packages] = await Promise.all([
|
||||
getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs),
|
||||
db.collection<Discount>("discounts").find({ domain }).toArray(),
|
||||
db.collection<Package>("packages").find().toArray(),
|
||||
])
|
||||
|
||||
return {
|
||||
props: serialize({ user, entities, discounts, packages }),
|
||||
|
||||
@@ -1,188 +1,216 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import {useEffect, useState} from "react";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||
import {getPermissionDoc} from "@/utils/permissions.be";
|
||||
import {User} from "@/interfaces/user";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import {getUsers} from "@/utils/users.be";
|
||||
import {BsTrash} from "react-icons/bs";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { Permission, PermissionType } from "@/interfaces/permissions";
|
||||
import { getPermissionDoc } from "@/utils/permissions.be";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { LayoutContext } from "@/components/High/Layout";
|
||||
import { getUsers } from "@/utils/users.be";
|
||||
import { BsTrash } from "react-icons/bs";
|
||||
import Select from "@/components/Low/Select";
|
||||
import Button from "@/components/Low/Button";
|
||||
import axios from "axios";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import {Type as UserType} from "@/interfaces/user";
|
||||
import {getGroups} from "@/utils/groups.be";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import { Type as UserType } from "@/interfaces/user";
|
||||
import { getGroups } from "@/utils/groups.be";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { redirect } from "@/utils";
|
||||
import { G } from "@react-pdf/renderer";
|
||||
interface BasicUser {
|
||||
id: string;
|
||||
name: string;
|
||||
type: UserType;
|
||||
id: string;
|
||||
name: string;
|
||||
type: UserType;
|
||||
}
|
||||
|
||||
interface PermissionWithBasicUsers {
|
||||
id: string;
|
||||
type: PermissionType;
|
||||
users: BasicUser[];
|
||||
id: string;
|
||||
type: PermissionType;
|
||||
users: BasicUser[];
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
export const getServerSideProps = withIronSessionSsr(
|
||||
async ({ req, res, params }) => {
|
||||
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
|
||||
const permission: Permission = await getPermissionDoc(params.id as string);
|
||||
// Fetch data from external API
|
||||
const [permission, users, groups] = await Promise.all([
|
||||
getPermissionDoc(params.id as string),
|
||||
getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
|
||||
getGroups(),
|
||||
]);
|
||||
|
||||
const allUserData: User[] = await getUsers();
|
||||
const groups = await getGroups();
|
||||
const userGroups = groups.filter((x) => x.admin === user.id);
|
||||
const userGroupsParticipants = userGroups.flatMap((x) => x.participants);
|
||||
const filteredGroups =
|
||||
user.type === "corporate"
|
||||
? userGroups
|
||||
: user.type === "mastercorporate"
|
||||
? groups.filter((x) => userGroupsParticipants.includes(x.admin))
|
||||
: groups;
|
||||
const filteredGroupsParticipants = filteredGroups.flatMap(
|
||||
(g) => g.participants
|
||||
);
|
||||
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
|
||||
? users.filter((u) => filteredGroupsParticipants.includes(u.id))
|
||||
: users;
|
||||
|
||||
const userGroups = groups.filter((x) => x.admin === user.id);
|
||||
const filteredGroups =
|
||||
user.type === "corporate"
|
||||
? userGroups
|
||||
: user.type === "mastercorporate"
|
||||
? groups.filter((x) => userGroups.flatMap((y) => y.participants).includes(x.admin))
|
||||
: groups;
|
||||
// const res = await fetch("api/permissions");
|
||||
// const permissions: Permission[] = await res.json();
|
||||
// Pass data to the page via props
|
||||
const usersData: BasicUser[] = permission.users.reduce(
|
||||
(acc: BasicUser[], userId) => {
|
||||
const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
|
||||
if (!!user) acc.push(user);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const users = allUserData.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
type: u.type,
|
||||
})) as BasicUser[];
|
||||
|
||||
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
|
||||
? users.filter((u) => filteredGroups.flatMap((g) => g.participants).includes(u.id))
|
||||
: users;
|
||||
|
||||
// const res = await fetch("api/permissions");
|
||||
// const permissions: Permission[] = await res.json();
|
||||
// Pass data to the page via props
|
||||
const usersData: BasicUser[] = permission.users.reduce((acc: BasicUser[], userId) => {
|
||||
const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
|
||||
if (!!user) acc.push(user);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
props: {
|
||||
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
||||
permission: {
|
||||
...permission,
|
||||
id: params.id,
|
||||
users: usersData,
|
||||
},
|
||||
user,
|
||||
users: filteredUsers,
|
||||
},
|
||||
};
|
||||
}, sessionOptions);
|
||||
return {
|
||||
props: {
|
||||
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
||||
permission: {
|
||||
...permission,
|
||||
id: params.id,
|
||||
users: usersData,
|
||||
},
|
||||
user,
|
||||
users: filteredUsers,
|
||||
},
|
||||
};
|
||||
},
|
||||
sessionOptions
|
||||
);
|
||||
|
||||
interface Props {
|
||||
permission: PermissionWithBasicUsers;
|
||||
user: User;
|
||||
users: BasicUser[];
|
||||
permission: PermissionWithBasicUsers;
|
||||
user: User;
|
||||
users: BasicUser[];
|
||||
}
|
||||
|
||||
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) => {
|
||||
setSelectedUsers((prev) => {
|
||||
if (value?.value) {
|
||||
return [...prev, value?.value];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
const removeUser = (id: string) => {
|
||||
setSelectedUsers((prev) => prev.filter((u) => u !== id));
|
||||
};
|
||||
const onChange = (value: any) => {
|
||||
setSelectedUsers((prev) => {
|
||||
if (value?.value) {
|
||||
return [...prev, value?.value];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
const removeUser = (id: string) => {
|
||||
setSelectedUsers((prev) => prev.filter((u) => u !== id));
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
try {
|
||||
await axios.patch(`/api/permissions/${permission.id}`, {
|
||||
users: selectedUsers,
|
||||
});
|
||||
toast.success("Permission updated");
|
||||
} catch (err) {
|
||||
toast.error("Failed to update permission");
|
||||
}
|
||||
};
|
||||
const update = async () => {
|
||||
try {
|
||||
await axios.patch(`/api/permissions/${permission.id}`, {
|
||||
users: selectedUsers,
|
||||
});
|
||||
toast.success("Permission updated");
|
||||
} catch (err) {
|
||||
toast.error("Failed to update permission");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<Layout user={user} className="gap-6">
|
||||
<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>
|
||||
<div className="flex gap-3">
|
||||
<Select
|
||||
value={null}
|
||||
options={users
|
||||
.filter((u) => !selectedUsers.includes(u.id))
|
||||
.map((u) => ({
|
||||
label: `${u?.type}-${u?.name}`,
|
||||
value: u.id,
|
||||
}))}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Button onClick={update}>Update</Button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2>Blacklisted Users</h2>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{selectedUsers.map((userId) => {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
return (
|
||||
<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">
|
||||
{user?.type}-{user?.name}
|
||||
</span>
|
||||
<BsTrash style={{cursor: "pointer"}} onClick={() => removeUser(userId)} size={20} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2>Whitelisted Users</h2>
|
||||
<div className="flex flex-col gap-3 flex-wrap">
|
||||
{users
|
||||
.filter((user) => !selectedUsers.includes(user.id))
|
||||
.map((user) => {
|
||||
return (
|
||||
<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">
|
||||
{user?.type}-{user?.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
const { setClassName } = React.useContext(LayoutContext);
|
||||
|
||||
useEffect(() => {
|
||||
setClassName("gap-6");
|
||||
return () => setClassName("");
|
||||
}, [setClassName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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 />
|
||||
<>
|
||||
<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>
|
||||
<div className="flex gap-3">
|
||||
<Select
|
||||
value={null}
|
||||
options={users.reduce<{ label: string; value: string }[]>(
|
||||
(acc, u) => {
|
||||
if (!selectedUsers.includes(u.id))
|
||||
acc.push({ label: `${u?.type}-${u?.name}`, value: u.id });
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Button onClick={update}>Update</Button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2>Blacklisted Users</h2>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{selectedUsers.map((userId) => {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
return (
|
||||
<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">
|
||||
{user?.type}-{user?.name}
|
||||
</span>
|
||||
<BsTrash
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => removeUser(userId)}
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2>Whitelisted Users</h2>
|
||||
<div className="flex flex-col gap-3 flex-wrap">
|
||||
{users.map((user) => {
|
||||
if (!selectedUsers.includes(user.id))
|
||||
return (
|
||||
<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">
|
||||
{user?.type}-{user?.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +1,85 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import {Permission} from "@/interfaces/permissions";
|
||||
import {getPermissionDocs} from "@/utils/permissions.be";
|
||||
import {User} from "@/interfaces/user";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { Permission } from "@/interfaces/permissions";
|
||||
import { getPermissionDocs } from "@/utils/permissions.be";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { LayoutContext } from "@/components/High/Layout";
|
||||
import PermissionList from "@/components/PermissionList";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { redirect } from "@/utils";
|
||||
import React from "react";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/")
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
// Fetch data from external API
|
||||
const permissions: Permission[] = await getPermissionDocs();
|
||||
const filteredPermissions = permissions.filter((p) => {
|
||||
const permissionType = p.type.toString().toLowerCase();
|
||||
// Fetch data from external API
|
||||
const permissions: Permission[] = await getPermissionDocs();
|
||||
const filteredPermissions = permissions.filter((p) => {
|
||||
const permissionType = p.type.toString().toLowerCase();
|
||||
|
||||
if (user.type === "corporate") return !permissionType.includes("corporate") && !permissionType.includes("admin");
|
||||
if (user.type === "mastercorporate") return !permissionType.includes("mastercorporate") && !permissionType.includes("admin");
|
||||
if (user.type === "corporate")
|
||||
return (
|
||||
!permissionType.includes("corporate") &&
|
||||
!permissionType.includes("admin")
|
||||
);
|
||||
if (user.type === "mastercorporate")
|
||||
return (
|
||||
!permissionType.includes("mastercorporate") &&
|
||||
!permissionType.includes("admin")
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
// const res = await fetch("api/permissions");
|
||||
// const permissions: Permission[] = await res.json();
|
||||
// Pass data to the page via props
|
||||
return {
|
||||
props: {
|
||||
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
||||
permissions: filteredPermissions.map((p) => {
|
||||
const {users, ...rest} = p;
|
||||
return rest;
|
||||
}),
|
||||
user,
|
||||
},
|
||||
};
|
||||
// const res = await fetch("api/permissions");
|
||||
// const permissions: Permission[] = await res.json();
|
||||
// Pass data to the page via props
|
||||
return {
|
||||
props: {
|
||||
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
||||
permissions: filteredPermissions.map((p) => {
|
||||
const { users, ...rest } = p;
|
||||
return rest;
|
||||
}),
|
||||
user,
|
||||
},
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
permissions: Permission[];
|
||||
user: User;
|
||||
permissions: Permission[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function Page(props: Props) {
|
||||
const {permissions, user} = props;
|
||||
const { permissions, user } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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>
|
||||
<Layout user={user} className="gap-6">
|
||||
<h1 className="text-2xl font-semibold">Permissions</h1>
|
||||
<div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
|
||||
<PermissionList permissions={permissions} />
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
const { setClassName } = React.useContext(LayoutContext);
|
||||
React.useEffect(() => setClassName("gap-6"), [setClassName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>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>
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold">Permissions</h1>
|
||||
<div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
|
||||
<PermissionList permissions={permissions} />
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||
import { groupByDate } from "@/utils/stats";
|
||||
import moment from "moment";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import clsx from "clsx";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
@@ -21,212 +20,287 @@ import useTrainingContentStore from "@/stores/trainingContentStore";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
||||
import useGradingSystem from "@/hooks/useGrading";
|
||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
||||
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be";
|
||||
import {
|
||||
getGradingSystemByEntities,
|
||||
} from "@/utils/grading.be";
|
||||
import { Grading } from "@/interfaces";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import CardList from "@/components/High/CardList";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
import getPendingEvals from "@/utils/disabled.be";
|
||||
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return redirect("/login")
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return redirect("/login");
|
||||
|
||||
if (shouldRedirectHome(user)) return redirect("/")
|
||||
if (shouldRedirectHome(user)) return redirect("/");
|
||||
|
||||
const entityIDs = mapBy(user.entities, 'id')
|
||||
const entityIDs = mapBy(user.entities, "id");
|
||||
const isAdmin = checkAccess(user, ["admin", "developer"]);
|
||||
|
||||
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
|
||||
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
|
||||
const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id')))
|
||||
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id'))
|
||||
const entities = await getEntitiesWithRoles(isAdmin ? undefined : entityIDs);
|
||||
const entitiesIds = mapBy(entities, "id");
|
||||
const [users, assignments, gradingSystems, pendingSessionIds] =
|
||||
await Promise.all([
|
||||
isAdmin ? getUsers() : getEntitiesUsers(entitiesIds),
|
||||
isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds),
|
||||
getGradingSystemByEntities(entitiesIds),
|
||||
getPendingEvals(user.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: serialize({ user, users, assignments, entities, gradingSystems }),
|
||||
};
|
||||
return {
|
||||
props: serialize({
|
||||
user,
|
||||
users,
|
||||
assignments,
|
||||
entities,
|
||||
gradingSystems,
|
||||
isAdmin,
|
||||
pendingSessionIds,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
type Filter = "months" | "weeks" | "days" | "assignments" | undefined;
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
assignments: Assignment[];
|
||||
entities: EntityWithRoles[]
|
||||
gradingSystems: Grading[]
|
||||
user: User;
|
||||
users: User[];
|
||||
assignments: Assignment[];
|
||||
entities: EntityWithRoles[];
|
||||
gradingSystems: Grading[];
|
||||
pendingSessionIds: string[];
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const MAX_TRAINING_EXAMS = 10;
|
||||
|
||||
export default function History({ user, users, assignments, entities, gradingSystems }: Props) {
|
||||
const router = useRouter();
|
||||
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
|
||||
state.selectedUser,
|
||||
state.setSelectedUser,
|
||||
state.training,
|
||||
state.setTraining,
|
||||
]);
|
||||
export default function History({
|
||||
user,
|
||||
users,
|
||||
assignments,
|
||||
entities,
|
||||
gradingSystems,
|
||||
isAdmin,
|
||||
pendingSessionIds,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore(
|
||||
(state) => [
|
||||
state.selectedUser,
|
||||
state.setSelectedUser,
|
||||
state.training,
|
||||
state.setTraining,
|
||||
]
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState<Filter>();
|
||||
const [filter, setFilter] = useState<Filter>();
|
||||
|
||||
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
||||
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record')
|
||||
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<
|
||||
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 setTrainingStats = useTrainingContentStore((state) => state.setStats);
|
||||
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
|
||||
|
||||
const groupedStats = useMemo(() => groupByDate(
|
||||
stats.filter((x) => {
|
||||
if (
|
||||
(x.module === "writing" || x.module === "speaking") &&
|
||||
!x.isDisabled &&
|
||||
!x.solutions.every((y) => Object.keys(y).includes("evaluation"))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
), [stats])
|
||||
const groupedStats = useMemo(
|
||||
() =>
|
||||
groupByDate(
|
||||
stats.filter((x) => {
|
||||
if (
|
||||
(x.module === "writing" || x.module === "speaking") &&
|
||||
!x.isDisabled &&
|
||||
Array.isArray(x.solutions) &&
|
||||
!x.solutions.every((y) => Object.keys(y).includes("evaluation"))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
),
|
||||
[stats]
|
||||
);
|
||||
|
||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
|
||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (url: string) => {
|
||||
setTraining(false);
|
||||
};
|
||||
router.events.on("routeChangeStart", handleRouteChange);
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", handleRouteChange);
|
||||
};
|
||||
}, [router.events, setTraining]);
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (url: string) => {
|
||||
setTraining(false);
|
||||
};
|
||||
router.events.on("routeChangeStart", handleRouteChange);
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", handleRouteChange);
|
||||
};
|
||||
}, [router.events, setTraining]);
|
||||
|
||||
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => {
|
||||
if (filter && filter !== "assignments") {
|
||||
const filterDate = moment()
|
||||
.subtract({ [filter as string]: 1 })
|
||||
.format("x");
|
||||
const filteredStats: { [key: string]: Stat[] } = {};
|
||||
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => {
|
||||
if (filter && filter !== "assignments") {
|
||||
const filterDate = moment()
|
||||
.subtract({ [filter as string]: 1 })
|
||||
.format("x");
|
||||
const filteredStats: { [key: string]: Stat[] } = {};
|
||||
|
||||
Object.keys(stats).forEach((timestamp) => {
|
||||
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
|
||||
});
|
||||
return filteredStats;
|
||||
}
|
||||
Object.keys(stats).forEach((timestamp) => {
|
||||
if (timestamp >= filterDate)
|
||||
filteredStats[timestamp] = stats[timestamp];
|
||||
});
|
||||
return filteredStats;
|
||||
}
|
||||
|
||||
if (filter && filter === "assignments") {
|
||||
const filteredStats: { [key: string]: Stat[] } = {};
|
||||
if (filter && filter === "assignments") {
|
||||
const filteredStats: { [key: string]: Stat[] } = {};
|
||||
|
||||
Object.keys(stats).forEach((timestamp) => {
|
||||
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false))
|
||||
filteredStats[timestamp] = [...stats[timestamp].filter((s) => !!s.assignment)];
|
||||
});
|
||||
Object.keys(stats).forEach((timestamp) => {
|
||||
if (
|
||||
stats[timestamp]
|
||||
.map((s) => s.assignment === undefined)
|
||||
.includes(false)
|
||||
)
|
||||
filteredStats[timestamp] = [
|
||||
...stats[timestamp].filter((s) => !!s.assignment),
|
||||
];
|
||||
});
|
||||
|
||||
return filteredStats;
|
||||
}
|
||||
return filteredStats;
|
||||
}
|
||||
|
||||
return stats;
|
||||
};
|
||||
return stats;
|
||||
};
|
||||
|
||||
const handleTrainingContentSubmission = () => {
|
||||
if (groupedStats) {
|
||||
const groupedStatsByDate = filterStatsByDate(groupedStats);
|
||||
const allStats = Object.keys(groupedStatsByDate);
|
||||
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => {
|
||||
const timestamp = moduleAndTimestamp.split("-")[1];
|
||||
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
|
||||
accumulator[timestamp] = groupedStatsByDate[timestamp];
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
setTrainingStats(Object.values(selectedStats).flat());
|
||||
router.push("/training");
|
||||
}
|
||||
};
|
||||
const handleTrainingContentSubmission = () => {
|
||||
if (groupedStats) {
|
||||
const groupedStatsByDate = filterStatsByDate(groupedStats);
|
||||
const allStats = Object.keys(groupedStatsByDate);
|
||||
const selectedStats = selectedTrainingExams.reduce<
|
||||
Record<string, Stat[]>
|
||||
>((accumulator, moduleAndTimestamp) => {
|
||||
const timestamp = moduleAndTimestamp.split("-")[1];
|
||||
if (
|
||||
allStats.includes(timestamp) &&
|
||||
!accumulator.hasOwnProperty(timestamp)
|
||||
) {
|
||||
accumulator[timestamp] = groupedStatsByDate[timestamp];
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
setTrainingStats(Object.values(selectedStats).flat());
|
||||
router.push("/training");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredStats = useMemo(() =>
|
||||
Object.keys(filterStatsByDate(groupedStats))
|
||||
.sort((a, b) => parseInt(b) - parseInt(a)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[groupedStats, filter])
|
||||
const filteredStats = useMemo(
|
||||
() =>
|
||||
Object.keys(filterStatsByDate(groupedStats)).sort(
|
||||
(a, b) => parseInt(b) - parseInt(a)
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[groupedStats, filter]
|
||||
);
|
||||
|
||||
const customContent = (timestamp: string) => {
|
||||
const dateStats = groupedStats[timestamp];
|
||||
const statUser = findBy(users, 'id', dateStats[0]?.user)
|
||||
const customContent = (timestamp: string) => {
|
||||
const dateStats = groupedStats[timestamp];
|
||||
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 (
|
||||
<StatsGridItem
|
||||
key={uuidv4()}
|
||||
stats={dateStats}
|
||||
gradingSystems={gradingSystems}
|
||||
timestamp={timestamp}
|
||||
user={user}
|
||||
assignments={assignments}
|
||||
users={users}
|
||||
training={training}
|
||||
selectedTrainingExams={selectedTrainingExams}
|
||||
setSelectedTrainingExams={setSelectedTrainingExams}
|
||||
maxTrainingExams={MAX_TRAINING_EXAMS}
|
||||
renderPdfIcon={canDownload ? renderPdfIcon : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<StatsGridItem
|
||||
key={uuidv4()}
|
||||
stats={dateStats}
|
||||
gradingSystems={gradingSystems}
|
||||
timestamp={timestamp}
|
||||
user={user}
|
||||
assignments={assignments}
|
||||
users={users}
|
||||
training={training}
|
||||
selectedTrainingExams={selectedTrainingExams}
|
||||
setSelectedTrainingExams={setSelectedTrainingExams}
|
||||
maxTrainingExams={MAX_TRAINING_EXAMS}
|
||||
renderPdfIcon={canDownload ? renderPdfIcon : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Record | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user}>
|
||||
<RecordFilter user={user} users={users} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}>
|
||||
{training && (
|
||||
<div className="flex flex-row">
|
||||
<div className="font-semibold text-2xl mr-4">
|
||||
Select up to 10 exercises
|
||||
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
|
||||
</div>
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
disabled={selectedTrainingExams.length == 0}
|
||||
onClick={handleTrainingContentSubmission}>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</RecordFilter>
|
||||
useEvaluationPolling(
|
||||
pendingSessionIds ? pendingSessionIds : [],
|
||||
"records",
|
||||
user.id
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Record | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<>
|
||||
<RecordFilter
|
||||
user={user}
|
||||
isAdmin={isAdmin}
|
||||
entities={entities}
|
||||
filterState={{ filter: filter, setFilter: setFilter }}
|
||||
>
|
||||
{training && (
|
||||
<div className="flex flex-row">
|
||||
<div className="font-semibold text-2xl mr-4">
|
||||
Select up to 10 exercises
|
||||
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
|
||||
</div>
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
disabled={selectedTrainingExams.length == 0}
|
||||
onClick={handleTrainingContentSubmission}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</RecordFilter>
|
||||
|
||||
{filteredStats.length > 0 && !isStatsLoading && (
|
||||
<CardList list={filteredStats} renderCard={customContent} searchFields={[]} pageSize={30} className="lg:!grid-cols-3" />
|
||||
)}
|
||||
{filteredStats.length === 0 && !isStatsLoading && (
|
||||
<span className="font-semibold ml-1">No record to display...</span>
|
||||
)}
|
||||
{isStatsLoading && (
|
||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{filteredStats.length > 0 && !isStatsLoading && (
|
||||
<CardList
|
||||
list={filteredStats}
|
||||
renderCard={customContent}
|
||||
searchFields={[]}
|
||||
pageSize={30}
|
||||
className="lg:!grid-cols-3"
|
||||
/>
|
||||
)}
|
||||
{filteredStats.length === 0 && !isStatsLoading && (
|
||||
<span className="font-semibold ml-1">No record to display...</span>
|
||||
)}
|
||||
{isStatsLoading && (
|
||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ import clsx from "clsx";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import RegisterIndividual from "./(register)/RegisterIndividual";
|
||||
import RegisterCorporate from "./(register)/RegisterCorporate";
|
||||
import EmailVerification from "./(auth)/EmailVerification";
|
||||
import {sendEmailVerification} from "@/utils/email";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import axios from "axios";
|
||||
|
||||
export const getServerSideProps = (context: any) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user