Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bfb94d01b | ||
|
|
df84aaadf4 | ||
|
|
2789660e8a | ||
|
|
a78e6eb64f | ||
|
|
6c7d189957 | ||
|
|
31f2a21a76 | ||
|
|
c49b1c8070 | ||
|
|
d78654a30f | ||
|
|
655e019bf6 | ||
|
|
d7a8f496c0 | ||
|
|
5e363e9951 | ||
|
|
3370f3c648 | ||
|
|
d77336374d | ||
|
|
e765dea106 | ||
|
|
75fb9490e0 | ||
|
|
3ef7998193 | ||
|
|
32cd8495d6 | ||
|
|
4e3cfec9e8 | ||
|
|
ba8cc342b1 | ||
|
|
dd8f821e35 | ||
|
|
a4ef2222e2 | ||
|
|
93d9e49358 | ||
|
|
5d0a3acbee | ||
|
|
340ff5a30a | ||
|
|
37908423eb | ||
|
|
b388ee399f | ||
|
|
4ac11df6ae | ||
|
|
14e2702aca | ||
|
|
fec3b51553 | ||
|
|
53d6b0dd51 | ||
|
|
d8386bdd8e | ||
|
|
df2f83e496 | ||
|
|
e214d8b598 | ||
|
|
c14f16c97a | ||
|
|
ca2cf739ee | ||
|
|
d432fb4bc4 | ||
|
|
d5bffc9bad | ||
|
|
75b4643918 | ||
|
|
9ae6b8e894 | ||
|
|
6f6c5a4209 | ||
|
|
769b1b39d3 | ||
|
|
4bb12c7f01 | ||
|
|
a80a342ae2 | ||
|
|
e5e60fcce9 | ||
|
|
b175d8797e | ||
|
|
f06349e350 | ||
|
|
34caf9986c | ||
|
|
3a3d3d014d | ||
|
|
c49c303f20 | ||
|
|
cbe353c2c5 | ||
|
|
991adede96 | ||
|
|
f95bce6fa2 | ||
|
|
1dd6cead9e | ||
|
|
5a72ebaea1 | ||
|
|
00d2a7c2ad | ||
|
|
a6e122e82d | ||
|
|
bf2aa29b98 | ||
|
|
cf12a4ed4f | ||
|
|
8711802b97 | ||
|
|
36afde8aa4 | ||
|
|
752881df41 | ||
|
|
63604b68e2 | ||
|
|
d74aa39076 | ||
|
|
c3849518fb | ||
|
|
7fb5e1a62b | ||
|
|
4b405297f2 | ||
|
|
f0849b9b42 | ||
|
|
845a5aa9dc | ||
|
|
d48c7b0d03 | ||
|
|
6692c201e4 | ||
|
|
f4c7961caa | ||
|
|
b215885dc6 | ||
|
|
de15eb5ee1 | ||
|
|
d3385caaf8 | ||
|
|
19f2193414 | ||
|
|
d59b654ac2 | ||
|
|
29b6a02118 | ||
|
|
b77476dc9a | ||
|
|
5a685ebe80 | ||
|
|
835a9bee03 | ||
|
|
16545d2075 | ||
|
|
b684262759 | ||
|
|
ac539332e6 | ||
|
|
ed87c8b163 | ||
|
|
e33ab315ad | ||
|
|
1feef5c419 | ||
|
|
a0229cd971 | ||
|
|
662e3b0266 | ||
|
|
b9aec7261f | ||
|
|
54a9f6869a | ||
|
|
9de4cba8e8 | ||
|
|
7d750dc584 | ||
|
|
98ba0bfc04 | ||
|
|
f89b42c41c | ||
|
|
c968044160 | ||
|
|
5d727fc528 | ||
|
|
bdc5ff7797 | ||
|
|
011c6e9e30 | ||
|
|
42a8ec2f8a | ||
|
|
58aebaa66c | ||
|
|
b69b6e6c77 | ||
|
|
86af876f01 | ||
|
|
b685259dc7 | ||
|
|
16b959fb7a | ||
|
|
a40ae04aa3 | ||
|
|
db95fc5681 | ||
|
|
8db47a3962 | ||
|
|
ab81a1753d | ||
|
|
c98af863c3 | ||
|
|
73610dc273 | ||
|
|
37216e2a5a | ||
|
|
ac072b0a5a | ||
|
|
2c0153e055 | ||
|
|
2eff08bf86 | ||
|
|
f71a7182dd | ||
|
|
1f7639a30e | ||
|
|
41d09eaad8 | ||
|
|
f6b0c96b3b | ||
|
|
dcd25465fd | ||
|
|
c921d54d50 | ||
|
|
a4f60455b5 | ||
|
|
a0936cb1a4 | ||
|
|
aa76c2b54b | ||
|
|
4e81c08adb | ||
|
|
4895f00184 | ||
|
|
f727ab4792 | ||
|
|
1c75a0e59c | ||
|
|
e36b24ea3f | ||
|
|
8f8d5e5640 | ||
|
|
73e2e95449 | ||
|
|
48187fc7f2 | ||
|
|
01222b3a13 | ||
|
|
4d788e13b4 | ||
|
|
39a397d262 | ||
|
|
50d2841349 | ||
|
|
f485c782f3 | ||
|
|
c2c9b3374c | ||
|
|
66d23b4140 | ||
|
|
580e319fb9 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,4 +40,6 @@ next-env.d.ts
|
||||
.env
|
||||
.yarn/*
|
||||
.history*
|
||||
__ENV.js
|
||||
__ENV.js
|
||||
|
||||
settings.json
|
||||
@@ -39,6 +39,7 @@
|
||||
"country-codes-list": "^1.6.11",
|
||||
"currency-symbol-map": "^5.1.0",
|
||||
"daisyui": "^3.1.5",
|
||||
"deep-diff": "^1.0.2",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
"exceljs": "^4.4.0",
|
||||
@@ -97,6 +98,7 @@
|
||||
"devDependencies": {
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/deep-diff": "^1.0.5",
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@types/howler": "^2.2.11",
|
||||
"@types/lodash": "^4.14.191",
|
||||
@@ -112,5 +114,6 @@
|
||||
"husky": "^8.0.3",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
51
scripts/updatePrivateFieldExams.js
Normal file
51
scripts/updatePrivateFieldExams.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { MongoClient } from "mongodb";
|
||||
const uri = process.env.MONGODB_URI || "";
|
||||
const options = {
|
||||
maxPoolSize: 10,
|
||||
};
|
||||
const dbName = process.env.MONGODB_DB; // change this to prod db when needed
|
||||
async function migrateData() {
|
||||
const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"];
|
||||
const client = new MongoClient(uri, options);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("Connected to MongoDB");
|
||||
if (!process.env.MONGODB_DB) {
|
||||
throw new Error("Missing env var: MONGODB_DB");
|
||||
}
|
||||
const db = client.db(dbName);
|
||||
for (const string of MODULE_ARRAY) {
|
||||
const collection = db.collection(string);
|
||||
const result = await collection.updateMany(
|
||||
{ private: { $exists: false } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
const result2 = await collection.updateMany(
|
||||
{ private: true },
|
||||
{ $set: { access: "private" }, $unset: { private: "" } }
|
||||
);
|
||||
const result1 = await collection.updateMany(
|
||||
{ private: { $exists: true } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
console.log(
|
||||
`Updated ${
|
||||
result.modifiedCount + result1.modifiedCount
|
||||
} documents to "access: public" in ${string}`
|
||||
);
|
||||
console.log(
|
||||
`Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}`
|
||||
);
|
||||
}
|
||||
console.log("Migration completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log("MongoDB connection closed.");
|
||||
}
|
||||
}
|
||||
//migrateData(); // uncomment to run the migration
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExam, getExamById} from "@/utils/exams";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
import {Menu} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||
import { useState} from "react";
|
||||
import { BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||
|
||||
@@ -13,6 +13,7 @@ import validateBlanks from "../validateBlanks";
|
||||
import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
interface Word {
|
||||
letter: string;
|
||||
@@ -72,6 +73,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
...local,
|
||||
text: blanksState.text,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -145,6 +147,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -189,6 +192,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -217,6 +221,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -234,6 +239,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
import MCOption from "./MCOption";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
@@ -69,6 +70,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...local,
|
||||
text: blanksState.text,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -139,6 +141,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -168,6 +171,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -217,6 +221,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...prev,
|
||||
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
@@ -234,6 +239,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
|
||||
blanksMissingWords.forEach(blank => {
|
||||
const newMCOption: FillBlanksMCOption = {
|
||||
uuid: uuidv4(),
|
||||
id: blank.id.toString(),
|
||||
options: {
|
||||
A: 'Option A',
|
||||
@@ -249,6 +255,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
@@ -98,6 +99,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
sentences: [
|
||||
...local.sentences,
|
||||
{
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
sentence: "",
|
||||
solution: ""
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import Alert, { AlertItem } from "../../Shared/Alert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
||||
@@ -57,6 +58,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
||||
{
|
||||
prompt: "",
|
||||
solution: "",
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
options,
|
||||
variant: "text"
|
||||
|
||||
@@ -18,6 +18,7 @@ import SortableQuestion from '../../Shared/SortableQuestion';
|
||||
import setEditingAlert from '../../Shared/setEditingAlert';
|
||||
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
interface MultipleChoiceProps {
|
||||
exercise: MultipleChoiceExercise;
|
||||
@@ -120,6 +121,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
||||
{
|
||||
prompt: "",
|
||||
solution: "",
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
options,
|
||||
variant: "text"
|
||||
|
||||
@@ -16,6 +16,7 @@ import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
@@ -50,6 +51,7 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
||||
{
|
||||
prompt: "",
|
||||
solution: undefined,
|
||||
uuid: uuidv4(),
|
||||
id: newId
|
||||
}
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ import { validateEmptySolutions, validateQuestionText, validateWordCount } from
|
||||
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
|
||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
|
||||
@@ -105,6 +106,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
||||
const newId = (Math.max(...existingIds, 0) + 1).toString();
|
||||
|
||||
const newQuestion = {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
questionText: "New question"
|
||||
};
|
||||
@@ -113,6 +115,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
||||
const updatedText = reconstructText(updatedQuestions);
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
solution: [""]
|
||||
}];
|
||||
|
||||
@@ -17,6 +17,7 @@ import { validateQuestions, validateEmptySolutions, validateWordCount } from "./
|
||||
import Header from "../../Shared/Header";
|
||||
import BlanksFormEditor from "./BlanksFormEditor";
|
||||
import PromptEdit from "../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||
@@ -111,6 +112,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
||||
|
||||
const newLine = `New question with blank {{${newId}}}`;
|
||||
const updatedQuestions = [...parsedQuestions, {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
parts: parseLine(newLine),
|
||||
editingPlaceholders: true
|
||||
@@ -121,6 +123,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
||||
.join('\\n') + '\\n';
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
solution: [""]
|
||||
}];
|
||||
|
||||
@@ -19,7 +19,7 @@ interface SettingsEditorProps {
|
||||
children?: ReactNode;
|
||||
canPreview: boolean;
|
||||
canSubmit: boolean;
|
||||
submitModule: () => void;
|
||||
submitModule: (requiresApproval: boolean) => void;
|
||||
preview: () => void;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -148,18 +148,33 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
</div>
|
||||
</Dropdown>
|
||||
{children}
|
||||
<div className="flex flex-row justify-between mt-4">
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||
"disabled:cursor-not-allowed disabled:text-gray-200"
|
||||
)}
|
||||
onClick={submitModule}
|
||||
onClick={() => submitModule(true)}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<FaFileUpload className="mr-2" size={18} />
|
||||
Submit Module as Exam
|
||||
Submit module as exam for approval
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||
"disabled:cursor-not-allowed disabled:text-gray-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!confirm(`Are you sure you want to skip the approval process for this exam?`)) return;
|
||||
submitModule(false);
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<FaFileUpload className="mr-2" size={18} />
|
||||
Submit module as exam and skip approval process
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -171,7 +186,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
disabled={!canPreview}
|
||||
>
|
||||
<FaEye className="mr-2" size={18} />
|
||||
Preview Module
|
||||
Preview module
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -37,7 +38,7 @@ const LevelSettings: React.FC = () => {
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
access,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
|
||||
@@ -75,7 +76,7 @@ const LevelSettings: React.FC = () => {
|
||||
});
|
||||
});
|
||||
|
||||
const submitLevel = async () => {
|
||||
const submitLevel = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -194,17 +195,23 @@ const LevelSettings: React.FC = () => {
|
||||
category: s.settings.category
|
||||
};
|
||||
}).filter(part => part.exercises.length > 0),
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/level', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
|
||||
Array.from(audioMap.values()).forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
@@ -237,7 +244,7 @@ const LevelSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
} as LevelExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
|
||||
@@ -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 justify-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import ExercisePicker from "../../ExercisePicker";
|
||||
import SettingsEditor from "..";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { useCallback, useState } from "react";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||
import { ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||
import Option from "@/interfaces/option";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../../Hooks/useSettingsState";
|
||||
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
||||
import Input from "@/components/Low/Input";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
@@ -26,7 +20,7 @@ const ListeningSettings: React.FC = () => {
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
access,
|
||||
instructionsState
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
@@ -64,7 +58,7 @@ const ListeningSettings: React.FC = () => {
|
||||
}
|
||||
];
|
||||
|
||||
const submitListening = async () => {
|
||||
const submitListening = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -137,19 +131,25 @@ const ListeningSettings: React.FC = () => {
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
instructions: instructionsURL
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/listening', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
|
||||
} else {
|
||||
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
||||
@@ -185,7 +185,7 @@ const ListeningSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
instructions: instructionsState.currentInstructionsURL
|
||||
} as ListeningExam);
|
||||
setExerciseIndex(0);
|
||||
|
||||
@@ -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 justify-center px-2 pb-4 "
|
||||
>
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Topic (Optional)
|
||||
</label>
|
||||
<Input
|
||||
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;
|
||||
|
||||
@@ -14,130 +14,138 @@ import { toast } from "react-toastify";
|
||||
import ReadingComponents from "./components";
|
||||
|
||||
const ReadingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
type,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, access, type } =
|
||||
useExamEditorStore((state) => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection
|
||||
);
|
||||
const { localSettings, updateLocalAndScheduleGlobal } =
|
||||
useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
|
||||
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
|
||||
const currentSection = sections.find(
|
||||
(section) => section.sectionId == focusedSection
|
||||
)?.state as ReadingPart;
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
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",
|
||||
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 defaultPresets: Option[] = [
|
||||
{
|
||||
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",
|
||||
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(
|
||||
(s) =>
|
||||
(s.state as ReadingPart).exercises &&
|
||||
(s.state as ReadingPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const canPreviewOrSubmit = sections.some(
|
||||
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const submitReading = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ReadingExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
variant: sections.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
const submitReading = (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ReadingExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category,
|
||||
};
|
||||
}),
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
variant: sections.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
access,
|
||||
type: type!,
|
||||
};
|
||||
|
||||
axios.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
||||
})
|
||||
}
|
||||
axios
|
||||
.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(
|
||||
error.response.data.error ||
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercises = s.state as ReadingPart;
|
||||
return {
|
||||
...exercises,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
} as ReadingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router)
|
||||
}
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercises = s.state as ReadingPart;
|
||||
return {
|
||||
...exercises,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category,
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
access: access,
|
||||
type: type!,
|
||||
} as ReadingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Passage ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="reading"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitReading}
|
||||
>
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Passage ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="reading"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitReading}
|
||||
>
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingSettings;
|
||||
|
||||
@@ -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;
|
||||
@@ -29,7 +30,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { title, currentModule } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
const { focusedSection, difficulty, sections, minTimer, access } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
|
||||
|
||||
@@ -83,7 +84,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
});
|
||||
})();
|
||||
|
||||
const submitSpeaking = async () => {
|
||||
const submitSpeaking = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -180,16 +181,22 @@ const SpeakingSettings: React.FC = () => {
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
private: isPrivate,
|
||||
access,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/speaking', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
|
||||
Array.from(urlMap.values()).forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
@@ -232,7 +239,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
} as SpeakingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
|
||||
@@ -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();
|
||||
@@ -21,7 +23,7 @@ const WritingSettings: React.FC = () => {
|
||||
const {
|
||||
minTimer,
|
||||
difficulty,
|
||||
isPrivate,
|
||||
access,
|
||||
sections,
|
||||
focusedSection,
|
||||
type,
|
||||
@@ -79,14 +81,14 @@ const WritingSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
type: type!
|
||||
});
|
||||
setExerciseIndex(0);
|
||||
openDetachedTab("popout?type=Exam&module=writing", router)
|
||||
}
|
||||
|
||||
const submitWriting = async () => {
|
||||
const submitWriting = async (requiresApproval: boolean) => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
@@ -129,16 +131,22 @@ const WritingSettings: React.FC = () => {
|
||||
minTimer,
|
||||
module: "writing",
|
||||
id: title,
|
||||
requiresApproval: requiresApproval,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
type: type!
|
||||
};
|
||||
|
||||
const result = await axios.post(`/api/exam/writing`, exam)
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting exam:', 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import SectionRenderer from "./SectionRenderer";
|
||||
import Checkbox from "../Low/Checkbox";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
import { capitalize } from "lodash";
|
||||
import { Difficulty } from "@/interfaces/exam";
|
||||
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
||||
@@ -20,220 +19,325 @@ import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
||||
import Button from "../Low/Button";
|
||||
import ResetModule from "./Standalone/ResetModule";
|
||||
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import Option from "../../interfaces/option";
|
||||
|
||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
const DIFFICULTIES: Option[] = [
|
||||
{ value: "A1", label: "A1" },
|
||||
{ value: "A2", label: "A2" },
|
||||
{ value: "B1", label: "B1" },
|
||||
{ value: "B2", label: "B2" },
|
||||
{ value: "C1", label: "C1" },
|
||||
{ value: "C2", label: "C2" },
|
||||
];
|
||||
|
||||
const 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 ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings,
|
||||
};
|
||||
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
const ExamEditor: React.FC<{
|
||||
levelParts?: number;
|
||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||
entitiesAllowConfExams: EntityWithRoles[];
|
||||
entitiesAllowPublicExams: EntityWithRoles[];
|
||||
}> = ({
|
||||
levelParts = 0,
|
||||
entitiesAllowEditPrivacy = [],
|
||||
entitiesAllowConfExams = [],
|
||||
entitiesAllowPublicExams = [],
|
||||
}) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
sections,
|
||||
minTimer,
|
||||
expandedSections,
|
||||
examLabel,
|
||||
access,
|
||||
difficulty,
|
||||
sectionLabels,
|
||||
importModule,
|
||||
} = useExamEditorStore((state) => state.modules[currentModule]);
|
||||
|
||||
// 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])
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(
|
||||
levelParts !== 0 ? levelParts : 1
|
||||
);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
|
||||
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;
|
||||
}
|
||||
// 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]);
|
||||
|
||||
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 = useMemo(
|
||||
() => sections.map((section) => section.sectionId),
|
||||
[sections]
|
||||
);
|
||||
|
||||
const updateModule = useCallback(
|
||||
(updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const toggleSection = useCallback(
|
||||
(sectionId: number) => {
|
||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||
toast.error("Include at least one section!");
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
||||
},
|
||||
[dispatch, expandedSections, sectionIds]
|
||||
);
|
||||
|
||||
const Settings = useMemo(
|
||||
() => ModuleSettings[currentModule],
|
||||
[currentModule]
|
||||
);
|
||||
|
||||
const showImport = useMemo(
|
||||
() =>
|
||||
importModule && ["reading", "listening", "level"].includes(currentModule),
|
||||
[importModule, currentModule]
|
||||
);
|
||||
|
||||
const accessTypeOptions = useMemo(() => {
|
||||
let options: Option[] = [{ value: "private", label: "Private" }];
|
||||
if (entitiesAllowConfExams.length > 0) {
|
||||
options.push({ value: "confidential", label: "Confidential" });
|
||||
}
|
||||
if (entitiesAllowPublicExams.length > 0) {
|
||||
options.push({ value: "public", label: "Public" });
|
||||
}
|
||||
return options;
|
||||
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
|
||||
|
||||
const updateLevelParts = useCallback((parts: number) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showImport ? (
|
||||
<ImportOrStartFromScratch
|
||||
module={currentModule}
|
||||
setNumberOfLevelParts={updateLevelParts}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isResetModuleOpen && (
|
||||
<ResetModule
|
||||
module={currentModule}
|
||||
isOpen={isResetModuleOpen}
|
||||
setIsOpen={setIsResetModuleOpen}
|
||||
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 w-full",
|
||||
sectionLabels.length > 3 ? "-2xl:flex-col" : "-xl:flex-col"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="flex flex-col gap-3 ">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Timer
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) =>
|
||||
updateModule({
|
||||
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||
})
|
||||
}
|
||||
value={minTimer}
|
||||
className="max-w-[125px] min-w-[100px] w-min"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 ">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Difficulty
|
||||
</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values
|
||||
? values.map((v) => v.value as Difficulty)
|
||||
: [];
|
||||
updateModule({ difficulty: selectedDifficulties });
|
||||
}}
|
||||
value={
|
||||
difficulty
|
||||
? (Array.isArray(difficulty)
|
||||
? 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-3">
|
||||
{sectionLabels.map(({ id, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
sectionIds.includes(id)
|
||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||
: "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="max-w-[200px] w-full">
|
||||
<Select
|
||||
label="Access Type"
|
||||
disabled={
|
||||
accessTypeOptions.length === 0 ||
|
||||
entitiesAllowEditPrivacy.length === 0
|
||||
}
|
||||
options={accessTypeOptions}
|
||||
onChange={(value) => {
|
||||
if (value?.value) {
|
||||
updateModule({ access: value.value! as AccessType });
|
||||
}
|
||||
}}
|
||||
value={{ value: access, label: capitalize(access) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
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 -xl: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,165 @@
|
||||
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 { 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>
|
||||
) : (
|
||||
rows.length === 0 && (
|
||||
<div className="w-full flex justify-center items-start">
|
||||
<span className="text-xl text-gray-500">No data found...</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {useListSearch} from "@/hooks/useListSearch";
|
||||
import usePagination from "@/hooks/usePagination";
|
||||
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import {useMemo, useState} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
const SIZE = 25;
|
||||
|
||||
|
||||
@@ -48,6 +48,15 @@ export default function AsyncSelect({
|
||||
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);
|
||||
@@ -77,7 +86,7 @@ export default function AsyncSelect({
|
||||
filterOption={null}
|
||||
loadingMessage={() => "Loading..."}
|
||||
onInputChange={(inputValue) => {
|
||||
loadOptions(inputValue);
|
||||
setInputValue(inputValue);
|
||||
}}
|
||||
options={options}
|
||||
value={value}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { checkAccess } from "@/utils/permissions";
|
||||
import Select from "../Low/Select";
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useRecordStore from "@/stores/recordStore";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { mapBy } from "@/utils";
|
||||
@@ -44,13 +42,13 @@ const RecordFilter: React.FC<Props> = ({
|
||||
|
||||
const [entity, setEntity] = useState<string>();
|
||||
|
||||
const [, setStatsUserId] = useRecordStore((state) => [
|
||||
const [selectedUser, setStatsUserId] = useRecordStore((state) => [
|
||||
state.selectedUser,
|
||||
state.setSelectedUser,
|
||||
]);
|
||||
|
||||
const entitiesToSearch = useMemo(() => {
|
||||
if(entity) return entity
|
||||
const entitiesToSearch = useMemo(() => {
|
||||
if (entity) return entity;
|
||||
if (isAdmin) return undefined;
|
||||
return mapBy(entities, "id");
|
||||
}, [entities, entity, isAdmin]);
|
||||
@@ -68,7 +66,15 @@ const RecordFilter: React.FC<Props> = ({
|
||||
entities,
|
||||
"view_student_record"
|
||||
);
|
||||
|
||||
|
||||
const selectedUserValue = useMemo(
|
||||
() =>
|
||||
users.find((u) => u.id === selectedUser) || {
|
||||
value: user.id,
|
||||
label: `${user.name} - ${user.email}`,
|
||||
},
|
||||
[selectedUser, user, users]
|
||||
);
|
||||
|
||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
||||
|
||||
@@ -118,10 +124,7 @@ const RecordFilter: React.FC<Props> = ({
|
||||
loadOptions={loadOptions}
|
||||
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
||||
options={users}
|
||||
defaultValue={{
|
||||
value: user.id,
|
||||
label: `${user.name} - ${user.email}`,
|
||||
}}
|
||||
defaultValue={selectedUserValue}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useRouter } from "next/router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { Exam, UserSolution } from "@/interfaces/exam";
|
||||
import ModuleBadge from "../ModuleBadge";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { findBy } from "@/utils";
|
||||
|
||||
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,487 @@ 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 entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [
|
||||
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level"
|
||||
])
|
||||
const entitiesAllowPaymentRecord = useAllowedEntities(
|
||||
user,
|
||||
entities,
|
||||
"view_payment_record"
|
||||
);
|
||||
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,
|
||||
viewPaymentRecords: true,
|
||||
viewGeneration: true,
|
||||
viewApprovalWorkflows: true,
|
||||
};
|
||||
}
|
||||
const sidebarPermissions: { [key: string]: boolean } = {
|
||||
viewExams: false,
|
||||
viewStats: false,
|
||||
viewRecords: false,
|
||||
viewTickets: false,
|
||||
viewClassrooms: false,
|
||||
viewSettings: false,
|
||||
viewPaymentRecords: 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["viewPaymentRecords"] = 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["viewPaymentRecords"] && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
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 -xl:px-4",
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
BsPen,
|
||||
BsXCircle,
|
||||
} from "react-icons/bs";
|
||||
import { totalExamsByModule } from "@/utils/stats";
|
||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||
import Button from "@/components/Low/Button";
|
||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
@@ -24,7 +22,7 @@ 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;
|
||||
@@ -41,7 +39,21 @@ export default function Selection({ user, page, onStart }: Props) {
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const { data: stats } = useFilterRecordsByUser<Stat[]>(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);
|
||||
@@ -77,7 +89,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
||||
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
value: reading || 0,
|
||||
tooltip: "The amount of reading exams performed.",
|
||||
},
|
||||
{
|
||||
@@ -85,7 +97,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
||||
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
value: listening || 0,
|
||||
tooltip: "The amount of listening exams performed.",
|
||||
},
|
||||
{
|
||||
@@ -93,7 +105,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
||||
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
value: writing || 0,
|
||||
tooltip: "The amount of writing exams performed.",
|
||||
},
|
||||
{
|
||||
@@ -101,7 +113,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
||||
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
value: speaking || 0,
|
||||
tooltip: "The amount of speaking exams performed.",
|
||||
},
|
||||
{
|
||||
@@ -109,7 +121,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
||||
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
value: level || 0,
|
||||
tooltip: "The amount of level exams performed.",
|
||||
},
|
||||
]}
|
||||
|
||||
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(entitiesString?: string) {
|
||||
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`, {params: { entityIds: entitiesString }})
|
||||
.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;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useExams() {
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Exam[]>("/api/exam")
|
||||
.then((response) => setExams(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Exam[]>(`/api/exam`)
|
||||
.then((response) => setExams(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
useEffect(getData, []);
|
||||
|
||||
return {exams, isLoading, isError, reload: getData};
|
||||
return { exams, isLoading, isError, reload: getData };
|
||||
}
|
||||
|
||||
@@ -1,60 +1,138 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import {useMemo, useState} from "react";
|
||||
import {BiChevronLeft} from "react-icons/bi";
|
||||
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BsChevronDoubleLeft,
|
||||
BsChevronDoubleRight,
|
||||
BsChevronLeft,
|
||||
BsChevronRight,
|
||||
} from "react-icons/bs";
|
||||
import Select from "../components/Low/Select";
|
||||
|
||||
export default function usePagination<T>(list: T[], size = 25) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(size);
|
||||
|
||||
const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]);
|
||||
const items = useMemo(
|
||||
() => list.slice(page * itemsPerPage, (page + 1) * itemsPerPage),
|
||||
[list, page, itemsPerPage]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (page * itemsPerPage >= list.length) setPage(0);
|
||||
}, [items, itemsPerPage, list.length, page]);
|
||||
|
||||
const render = () => (
|
||||
<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={page === 0} onClick={() => setPage((prev) => prev - 1)}>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<span className="opacity-80">
|
||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
||||
</span>
|
||||
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const itemsPerPageOptions = [25, 50, 100, 200];
|
||||
|
||||
const renderMinimal = () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronDoubleLeft />
|
||||
</button>
|
||||
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronLeft />
|
||||
</button>
|
||||
</div>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
||||
</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={(page + 1) * size >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronRight />
|
||||
</button>
|
||||
<button
|
||||
disabled={(page + 1) * size >= list.length}
|
||||
onClick={() => setPage(Math.floor(list.length / size))}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronDoubleRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const render = () => (
|
||||
<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={page === 0}
|
||||
onClick={() => setPage((prev) => prev - 1)}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<div className="flex flex-row items-center gap-1 w-56">
|
||||
<Select
|
||||
value={{
|
||||
value: itemsPerPage.toString(),
|
||||
label: itemsPerPage.toString(),
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setItemsPerPage(parseInt(value!.value ?? "25"))
|
||||
}
|
||||
options={itemsPerPageOptions.map((size) => ({
|
||||
label: size.toString(),
|
||||
value: size.toString(),
|
||||
}))}
|
||||
isClearable={false}
|
||||
styles={{
|
||||
control: (styles) => ({ ...styles, width: "100px" }),
|
||||
container: (styles) => ({ ...styles, width: "100px" }),
|
||||
}}
|
||||
/>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * itemsPerPage + 1} -{" "}
|
||||
{itemsPerPage * (page + 1) > list.length
|
||||
? list.length
|
||||
: itemsPerPage * (page + 1)}
|
||||
{list.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-[200px]"
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return {page, items, setPage, render, renderMinimal};
|
||||
const renderMinimal = () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronDoubleLeft />
|
||||
</button>
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((prev) => prev - 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronLeft />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1 w-56">
|
||||
<Select
|
||||
value={{
|
||||
value: itemsPerPage.toString(),
|
||||
label: itemsPerPage.toString(),
|
||||
}}
|
||||
onChange={(value) => setItemsPerPage(parseInt(value!.value ?? "25"))}
|
||||
options={itemsPerPageOptions.map((size) => ({
|
||||
label: size.toString(),
|
||||
value: size.toString(),
|
||||
}))}
|
||||
isClearable={false}
|
||||
styles={{
|
||||
control: (styles) => ({ ...styles, width: "100px" }),
|
||||
container: (styles) => ({ ...styles, width: "100px" }),
|
||||
}}
|
||||
/>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * itemsPerPage + 1} -{" "}
|
||||
{itemsPerPage * (page + 1) > list.length
|
||||
? list.length
|
||||
: itemsPerPage * (page + 1)}
|
||||
/ {list.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronRight />
|
||||
</button>
|
||||
<button
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage(Math.floor(list.length / itemsPerPage))}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronDoubleRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { page, items, setPage, render, renderMinimal };
|
||||
}
|
||||
|
||||
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,26 +1,28 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
const useTicketsListener = (userId?: string) => {
|
||||
const useTicketsListener = (userId?: string, canFetch?: boolean) => {
|
||||
const [assignedTickets, setAssignedTickets] = useState([]);
|
||||
|
||||
const getData = () => {
|
||||
const getData = useCallback(() => {
|
||||
axios
|
||||
.get("/api/tickets/assignedToUser")
|
||||
.then((response) => setAssignedTickets(response.data));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return;
|
||||
getData();
|
||||
}, [canFetch, getData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return;
|
||||
const intervalId = setInterval(() => {
|
||||
getData();
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [assignedTickets]);
|
||||
}, [assignedTickets, canFetch, getData]);
|
||||
|
||||
if (userId) {
|
||||
return {
|
||||
|
||||
72
src/interfaces/approval.workflow.ts
Normal file
72
src/interfaces/approval.workflow.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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,
|
||||
examChanges?: 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,3 @@
|
||||
import instructions from "@/pages/api/exam/media/instructions";
|
||||
import { Module } from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
@@ -10,6 +9,9 @@ export type Difficulty = BasicDifficulty | CEFRLevels;
|
||||
// Left easy, medium and hard to support older exam versions
|
||||
export type BasicDifficulty = "easy" | "medium" | "hard";
|
||||
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2";
|
||||
export const ACCESSTYPE = ["public", "private", "confidential"] as const;
|
||||
export type AccessType = typeof ACCESSTYPE[number];
|
||||
|
||||
|
||||
|
||||
export interface ExamBase {
|
||||
@@ -24,8 +26,10 @@ export interface ExamBase {
|
||||
shuffle?: boolean;
|
||||
createdBy?: string; // option as it has been added later
|
||||
createdAt?: string; // option as it has been added later
|
||||
private?: boolean;
|
||||
access: AccessType;
|
||||
label?: string;
|
||||
requiresApproval?: boolean;
|
||||
approved?: boolean;
|
||||
}
|
||||
export interface ReadingExam extends ExamBase {
|
||||
module: "reading";
|
||||
@@ -238,6 +242,7 @@ export interface InteractiveSpeakingExercise extends Section {
|
||||
}
|
||||
|
||||
export interface FillBlanksMCOption {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string;
|
||||
options: {
|
||||
A: string;
|
||||
@@ -255,6 +260,7 @@ export interface FillBlanksExercise {
|
||||
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
||||
allowRepetition?: boolean;
|
||||
solutions: {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "1"
|
||||
solution: string; // *EXAMPLE: "preserve"
|
||||
}[];
|
||||
@@ -278,6 +284,7 @@ export interface TrueFalseExercise {
|
||||
}
|
||||
|
||||
export interface TrueFalseQuestion {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "1"
|
||||
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||
solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True"
|
||||
@@ -290,6 +297,7 @@ export interface WriteBlanksExercise {
|
||||
id: string;
|
||||
text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
|
||||
solutions: {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "14"
|
||||
solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive)
|
||||
}[];
|
||||
@@ -316,12 +324,14 @@ export interface MatchSentencesExercise {
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseSentence {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string;
|
||||
sentence: string;
|
||||
solution: string;
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseOption {
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string;
|
||||
sentence: string;
|
||||
}
|
||||
@@ -343,6 +353,7 @@ export interface MultipleChoiceExercise {
|
||||
|
||||
export interface MultipleChoiceQuestion {
|
||||
variant: "image" | "text";
|
||||
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||
id: string; // *EXAMPLE: "1"
|
||||
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||
solution: string; // *EXAMPLE: "A"
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
src/lib/createWorkflowsOnExamCreation.ts
Normal file
84
src/lib/createWorkflowsOnExamCreation.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be";
|
||||
import client from "@/lib/mongodb";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
/* export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) {
|
||||
const results = await Promise.all(
|
||||
examEntities.map(async (entity) => {
|
||||
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
|
||||
if (!configuredWorkflow) {
|
||||
return { entity, created: false };
|
||||
}
|
||||
|
||||
configuredWorkflow.modules.push(examModule as Module);
|
||||
configuredWorkflow.name = examId;
|
||||
configuredWorkflow.examId = examId;
|
||||
configuredWorkflow.entityId = entity;
|
||||
configuredWorkflow.startDate = Date.now();
|
||||
|
||||
try {
|
||||
await createApprovalWorkflow("active-workflows", configuredWorkflow);
|
||||
return { entity, created: true };
|
||||
} catch (error: any) {
|
||||
return { entity, created: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter((r) => r.created).length;
|
||||
const totalCount = examEntities.length;
|
||||
|
||||
return {
|
||||
successCount,
|
||||
totalCount,
|
||||
};
|
||||
} */
|
||||
|
||||
// TEMPORARY BEHAVIOUR! ONLY THE FIRST CONFIGURED WORKFLOW FOUND IS STARTED
|
||||
export async function createApprovalWorkflowOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) {
|
||||
let successCount = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
for (const entity of examEntities) {
|
||||
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
|
||||
|
||||
if (!configuredWorkflow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCount = 1; // a workflow was found
|
||||
|
||||
configuredWorkflow.modules.push(examModule as Module);
|
||||
configuredWorkflow.name = examId;
|
||||
configuredWorkflow.examId = examId;
|
||||
configuredWorkflow.entityId = entity;
|
||||
configuredWorkflow.startDate = Date.now();
|
||||
configuredWorkflow.steps[0].completed = true;
|
||||
configuredWorkflow.steps[0].completedBy = examAuthor;
|
||||
configuredWorkflow.steps[0].completedDate = Date.now();
|
||||
|
||||
try {
|
||||
await createApprovalWorkflow("active-workflows", configuredWorkflow);
|
||||
successCount = 1;
|
||||
break; // Stop after the first success
|
||||
} catch (error: any) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// commented because they asked for every exam to stay confidential
|
||||
/* if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
|
||||
await db.collection(examModule).updateOne(
|
||||
{ id: examId },
|
||||
{ $set: { id: examId, access: "private" }},
|
||||
{ upsert: true }
|
||||
);
|
||||
} */
|
||||
|
||||
return {
|
||||
successCount,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,9 @@ if (!process.env.MONGODB_URI) {
|
||||
}
|
||||
|
||||
const uri = process.env.MONGODB_URI || "";
|
||||
const options = {};
|
||||
const options = {
|
||||
maxPoolSize: 10,
|
||||
};
|
||||
|
||||
let client: MongoClient;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
@@ -15,444 +13,587 @@ import ShortUniqueId from "short-unique-id";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import Modal from "@/components/Modal";
|
||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import Select from "@/components/Low/Select";
|
||||
import CodeGenImportSummary, { ExcelCodegenDuplicatesMap } from "@/components/ImportSummaries/Codegen";
|
||||
import CodeGenImportSummary, {
|
||||
ExcelCodegenDuplicatesMap,
|
||||
} from "@/components/ImportSummaries/Codegen";
|
||||
import { FaFileDownload } from "react-icons/fa";
|
||||
import { IoInformationCircleOutline } from "react-icons/io5";
|
||||
import { HiOutlineDocumentText } from "react-icons/hi";
|
||||
import CodegenTable from "@/components/Tables/CodeGenTable";
|
||||
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
const EMAIL_REGEX = new RegExp(
|
||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||
);
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
} = {
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
},
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[]
|
||||
onFinish: () => void;
|
||||
user: User;
|
||||
users: User[];
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) {
|
||||
const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined });
|
||||
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>();
|
||||
export default function BatchCodeGenerator({
|
||||
user,
|
||||
users,
|
||||
entities = [],
|
||||
permissions,
|
||||
onFinish,
|
||||
}: Props) {
|
||||
const [infos, setInfos] = useState<
|
||||
{ email: string; name: string; passport_id: string }[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user.subscriptionExpirationDate).toDate()
|
||||
: null
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||
const [parsedExcel, setParsedExcel] = useState<{
|
||||
rows?: any[];
|
||||
errors?: any[];
|
||||
}>({ rows: undefined, errors: undefined });
|
||||
const [duplicatedRows, setDuplicatedRows] = useState<{
|
||||
duplicates: ExcelCodegenDuplicatesMap;
|
||||
count: number;
|
||||
}>();
|
||||
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const schema = {
|
||||
'First Name': {
|
||||
prop: 'firstName',
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('First Name cannot be empty')
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
'Last Name': {
|
||||
prop: 'lastName',
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('Last Name cannot be empty')
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
'Passport/National ID': {
|
||||
prop: 'passport_id',
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('Passport/National ID cannot be empty')
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
'E-mail': {
|
||||
prop: 'email',
|
||||
required: true,
|
||||
type: (value: any) => {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new Error('Email cannot be empty')
|
||||
}
|
||||
if (!EMAIL_REGEX.test(value.trim())) {
|
||||
throw new Error('Invalid Email')
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
const schema = {
|
||||
"First Name": {
|
||||
prop: "firstName",
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("First Name cannot be empty");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
"Last Name": {
|
||||
prop: "lastName",
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("Last Name cannot be empty");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
"Passport/National ID": {
|
||||
prop: "passport_id",
|
||||
type: String,
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("Passport/National ID cannot be empty");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
"E-mail": {
|
||||
prop: "email",
|
||||
required: true,
|
||||
type: (value: any) => {
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error("Email cannot be empty");
|
||||
}
|
||||
if (!EMAIL_REGEX.test(value.trim())) {
|
||||
throw new Error("Invalid Email");
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(
|
||||
file.content, { schema, ignoreEmptyRows: false })
|
||||
.then((data) => {
|
||||
setParsedExcel(data)
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content, { schema, ignoreEmptyRows: false }).then(
|
||||
(data) => {
|
||||
setParsedExcel(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedExcel.rows) {
|
||||
const duplicates: ExcelCodegenDuplicatesMap = {
|
||||
email: new Map(),
|
||||
passport_id: new Map(),
|
||||
};
|
||||
const duplicateValues = new Set<string>();
|
||||
const duplicateRowIndices = new Set<number>();
|
||||
useEffect(() => {
|
||||
if (parsedExcel.rows) {
|
||||
const duplicates: ExcelCodegenDuplicatesMap = {
|
||||
email: new Map(),
|
||||
passport_id: new Map(),
|
||||
};
|
||||
const duplicateValues = new Set<string>();
|
||||
const duplicateRowIndices = new Set<number>();
|
||||
|
||||
const errorRowIndices = new Set(
|
||||
parsedExcel.errors?.map(error => error.row) || []
|
||||
);
|
||||
const errorRowIndices = new Set(
|
||||
parsedExcel.errors?.map((error) => error.row) || []
|
||||
);
|
||||
|
||||
parsedExcel.rows.forEach((row, index) => {
|
||||
if (!errorRowIndices.has(index + 2)) {
|
||||
(Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).forEach(field => {
|
||||
if (row !== null) {
|
||||
const value = row[field];
|
||||
if (value) {
|
||||
if (!duplicates[field].has(value)) {
|
||||
duplicates[field].set(value, [index + 2]);
|
||||
} else {
|
||||
const existingRows = duplicates[field].get(value);
|
||||
if (existingRows) {
|
||||
existingRows.push(index + 2);
|
||||
duplicateValues.add(value);
|
||||
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
parsedExcel.rows.forEach((row, index) => {
|
||||
if (!errorRowIndices.has(index + 2)) {
|
||||
(
|
||||
Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
|
||||
).forEach((field) => {
|
||||
if (row !== null) {
|
||||
const value = row[field];
|
||||
if (value) {
|
||||
if (!duplicates[field].has(value)) {
|
||||
duplicates[field].set(value, [index + 2]);
|
||||
} else {
|
||||
const existingRows = duplicates[field].get(value);
|
||||
if (existingRows) {
|
||||
existingRows.push(index + 2);
|
||||
duplicateValues.add(value);
|
||||
existingRows.forEach((rowNum) =>
|
||||
duplicateRowIndices.add(rowNum)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const info = parsedExcel.rows
|
||||
.map((row, index) => {
|
||||
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
|
||||
return undefined;
|
||||
}
|
||||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
|
||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||
return undefined;
|
||||
}
|
||||
const info = parsedExcel.rows
|
||||
.map((row, index) => {
|
||||
if (
|
||||
errorRowIndices.has(index + 2) ||
|
||||
duplicateRowIndices.has(index + 2) ||
|
||||
row === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
studentID,
|
||||
passport_id,
|
||||
email,
|
||||
phone,
|
||||
group,
|
||||
country,
|
||||
} = row;
|
||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
};
|
||||
}).filter((x) => !!x) as typeof infos;
|
||||
return {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
};
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos;
|
||||
|
||||
setInfos(info);
|
||||
}
|
||||
}, [entity, parsedExcel, type]);
|
||||
setInfos(info);
|
||||
}
|
||||
}, [entity, parsedExcel, type]);
|
||||
|
||||
const generateAndInvite = async () => {
|
||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||
const existingUsers = infos
|
||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||
.map((i) => users.find((u) => u.email === i.email))
|
||||
.filter((x) => !!x && x.type === "student") as User[];
|
||||
const generateAndInvite = async () => {
|
||||
const newUsers = infos.filter(
|
||||
(x) => !users.map((u) => u.email).includes(x.email)
|
||||
);
|
||||
const existingUsers = infos
|
||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||
.map((i) => users.find((u) => u.email === i.email))
|
||||
.filter((x) => !!x && x.type === "student") as User[];
|
||||
|
||||
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const newUsersSentence =
|
||||
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence =
|
||||
existingUsers.length > 0
|
||||
? `invite ${existingUsers.length} registered student(s)`
|
||||
: undefined;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence]
|
||||
.filter((x) => !!x)
|
||||
.join(" and ")}, are you sure you want to continue?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id })))
|
||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
setIsLoading(true);
|
||||
Promise.all(
|
||||
existingUsers.map(
|
||||
async (u) =>
|
||||
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||
)
|
||||
)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||
)
|
||||
)
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations.map((info, index) => ({ ...info, code: codes[index] })),
|
||||
expiryDate,
|
||||
entity
|
||||
})
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(
|
||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||
type,
|
||||
)} codes and they have been notified by e-mail!`,
|
||||
{ toastId: "success" },
|
||||
);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations.map((info, index) => ({
|
||||
...info,
|
||||
code: codes[index],
|
||||
})),
|
||||
expiryDate,
|
||||
entity,
|
||||
})
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(
|
||||
`Successfully generated${
|
||||
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
||||
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
||||
{ toastId: "success" }
|
||||
);
|
||||
|
||||
onFinish();
|
||||
return;
|
||||
}
|
||||
onFinish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
|
||||
const handleTemplateDownload = () => {
|
||||
const fileName = "BatchCodeTemplate.xlsx";
|
||||
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
||||
const handleTemplateDownload = () => {
|
||||
const fileName = "BatchCodeTemplate.xlsx";
|
||||
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
link.download = fileName;
|
||||
link.download = fileName;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
||||
<>
|
||||
<div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} />
|
||||
<h2 className="text-lg font-semibold">
|
||||
The uploaded document must:
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
be an Excel .xlsx document.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
only have a single spreadsheet with the following <b>exact same name</b> columns:
|
||||
<div className="py-4 pr-4">
|
||||
<table className="w-full bg-white">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} />
|
||||
<h2 className="text-lg font-semibold">
|
||||
Note that:
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
all incorrect e-mails will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all already registered e-mails will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all rows which contain duplicate values in the columns: "Passport/National ID", "E-mail", will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-lg p-4">
|
||||
<p className="text-gray-600">
|
||||
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full flex justify-between mt-6 gap-8">
|
||||
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white">
|
||||
Close
|
||||
</Button>
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
||||
<>
|
||||
<div className="flex font-bold text-xl justify-center text-gray-700">
|
||||
<span>Excel File Format</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiOutlineDocumentText
|
||||
className={`w-5 h-5 text-mti-purple-light`}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">
|
||||
The uploaded document must:
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
be an Excel .xlsx document.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
only have a single spreadsheet with the following{" "}
|
||||
<b>exact same name</b> columns:
|
||||
<div className="py-4 pr-4">
|
||||
<table className="w-full bg-white">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
First Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Last Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Passport/National ID
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
E-mail
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IoInformationCircleOutline
|
||||
className={`w-5 h-5 text-mti-purple-light`}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">Note that:</h2>
|
||||
</div>
|
||||
<ul className="flex flex-col pl-10 gap-2">
|
||||
<li className="text-gray-700 list-disc">
|
||||
all incorrect e-mails will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all already registered e-mails will be ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all rows which contain duplicate values in the columns:
|
||||
"Passport/National ID", "E-mail", will be
|
||||
ignored.
|
||||
</li>
|
||||
<li className="text-gray-700 list-disc">
|
||||
all of the e-mails in the file will receive an e-mail to join
|
||||
EnCoach with the role selected below.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-lg p-4">
|
||||
<p className="text-gray-600">
|
||||
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full flex justify-between mt-6 gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => setShowHelp(false)}
|
||||
variant="outline"
|
||||
className="self-end w-full bg-white"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFileDownload size={24} />
|
||||
Download Template
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-end justify-between">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||
data-tip="Excel File Format"
|
||||
>
|
||||
<IoInformationCircleOutline size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||
})
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>}
|
||||
{infos.length !== 0 && (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span>
|
||||
<CodegenTable infos={infos} />
|
||||
</div>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
Generate & Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={handleTemplateDownload}
|
||||
variant="solid"
|
||||
className="self-end w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFileDownload size={24} />
|
||||
Download Template
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-end justify-between">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Choose an Excel file
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||
data-tip="Excel File Format"
|
||||
>
|
||||
<IoInformationCircleOutline size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user &&
|
||||
checkAccess(user, [
|
||||
"developer",
|
||||
"admin",
|
||||
"corporate",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{
|
||||
value: (entities || [])[0]?.id,
|
||||
label: (entities || [])[0]?.label,
|
||||
}}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Select the type of user they should be
|
||||
</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS).reduce((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||
acc.push(
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
);
|
||||
return acc;
|
||||
}, [] as JSX.Element[])}
|
||||
</select>
|
||||
)}
|
||||
{infos.length > 0 && (
|
||||
<CodeGenImportSummary
|
||||
infos={infos}
|
||||
parsedExcel={parsedExcel}
|
||||
duplicateRows={duplicatedRows}
|
||||
/>
|
||||
)}
|
||||
{infos.length !== 0 && (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<span className="text-mti-gray-dim text-base font-normal">
|
||||
Codes will be sent to:
|
||||
</span>
|
||||
<CodegenTable infos={infos} />
|
||||
</div>
|
||||
)}
|
||||
{checkAccess(
|
||||
user,
|
||||
["developer", "admin", "corporate", "mastercorporate"],
|
||||
permissions,
|
||||
"createCodes"
|
||||
) && (
|
||||
<Button
|
||||
onClick={generateAndInvite}
|
||||
disabled={
|
||||
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||
}
|
||||
>
|
||||
Generate & Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
@@ -13,173 +12,225 @@ import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import Select from "@/components/Low/Select";
|
||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
} = {
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
},
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[]
|
||||
onFinish: () => void;
|
||||
user: User;
|
||||
permissions: PermissionType[];
|
||||
entities: EntityWithRoles[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
export default function CodeGenerator({
|
||||
user,
|
||||
entities = [],
|
||||
permissions,
|
||||
onFinish,
|
||||
}: Props) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user.subscriptionExpirationDate).toDate()
|
||||
: null
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const code = uid.randomUUID(6);
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const code = uid.randomUUID(6);
|
||||
|
||||
axios
|
||||
.post("/api/code", { type, codes: [code], expiryDate, entity })
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||
toastId: "success",
|
||||
});
|
||||
setGeneratedCode(code);
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.post("/api/code", { type, codes: [code], expiryDate, entity })
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||
toastId: "success",
|
||||
});
|
||||
setGeneratedCode(code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
});
|
||||
};
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
User Code Generator
|
||||
</label>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{
|
||||
value: (entities || [])[0]?.id,
|
||||
label: (entities || [])[0]?.label,
|
||||
}}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||
})
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||
acc.push(x);
|
||||
return acc;
|
||||
}, [])}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
data-tip="Click to copy"
|
||||
onClick={() => {
|
||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||
}}>
|
||||
{generatedCode}
|
||||
</div>
|
||||
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
||||
</div>
|
||||
);
|
||||
{checkAccess(user, [
|
||||
"developer",
|
||||
"admin",
|
||||
"corporate",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{checkAccess(
|
||||
user,
|
||||
["developer", "admin", "corporate", "mastercorporate"],
|
||||
permissions,
|
||||
"createCodes"
|
||||
) && (
|
||||
<Button
|
||||
onClick={() => generateCode(type)}
|
||||
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Generated Code:
|
||||
</label>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
data-tip="Click to copy"
|
||||
onClick={() => {
|
||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||
}}
|
||||
>
|
||||
{generatedCode}
|
||||
</div>
|
||||
{generatedCode && (
|
||||
<span className="text-sm text-mti-gray-dim font-light">
|
||||
Give this code to the user to complete their registration
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
Copy 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"
|
||||
>
|
||||
Copy 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 Changes to entities
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import axios from "axios";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
|
||||
@@ -15,170 +15,210 @@ import { findBy, mapBy } from "@/utils";
|
||||
import useEntitiesCodes from "@/hooks/useEntitiesCodes";
|
||||
import Table from "@/components/High/Table";
|
||||
|
||||
type TableData = Code & { entity?: EntityWithRoles, creator?: User }
|
||||
type TableData = Code & { entity?: EntityWithRoles; creator?: User };
|
||||
const columnHelper = createColumnHelper<TableData>();
|
||||
|
||||
export default function CodeList({ user, entities, canDeleteCodes }
|
||||
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) {
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
export default function CodeList({
|
||||
user,
|
||||
entities,
|
||||
canDeleteCodes,
|
||||
}: {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
canDeleteCodes?: boolean;
|
||||
}) {
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
|
||||
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities])
|
||||
const entityIDs = useMemo(() => mapBy(entities, "id"), [entities]);
|
||||
|
||||
const { users } = useUsers();
|
||||
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs)
|
||||
const { users } = useUsers();
|
||||
const { codes, reload, isLoading } = useEntitiesCodes(
|
||||
isAdmin(user) ? undefined : entityIDs
|
||||
);
|
||||
|
||||
const data: TableData[] = useMemo(() => codes.map((code) => ({
|
||||
...code,
|
||||
entity: findBy(entities, 'id', code.entity),
|
||||
creator: findBy(users, 'id', code.creator)
|
||||
})) as TableData[], [codes, entities, users])
|
||||
const data: TableData[] = useMemo(
|
||||
() =>
|
||||
codes.map((code) => ({
|
||||
...code,
|
||||
entity: findBy(entities, "id", code.entity),
|
||||
creator: findBy(users, "id", code.creator),
|
||||
})) as TableData[],
|
||||
[codes, entities, users]
|
||||
);
|
||||
|
||||
const toggleCode = (id: string) => {
|
||||
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
const toggleCode = (id: string) => {
|
||||
setSelectedCodes((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// const toggleAllCodes = (checked: boolean) => {
|
||||
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
|
||||
// const toggleAllCodes = (checked: boolean) => {
|
||||
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
|
||||
|
||||
// return setSelectedCodes([]);
|
||||
// };
|
||||
// return setSelectedCodes([]);
|
||||
// };
|
||||
|
||||
const deleteCodes = async (codes: string[]) => {
|
||||
if (!canDeleteCodes) return
|
||||
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
|
||||
const deleteCodes = async (codes: string[]) => {
|
||||
if (!canDeleteCodes) return;
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
|
||||
)
|
||||
return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
codes.forEach((code) => params.append("code", code));
|
||||
const params = new URLSearchParams();
|
||||
codes.forEach((code) => params.append("code", code));
|
||||
|
||||
axios
|
||||
.delete(`/api/code?${params.toString()}`)
|
||||
.then(() => {
|
||||
toast.success(`Deleted the codes!`);
|
||||
setSelectedCodes([]);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/code?${params.toString()}`)
|
||||
.then(() => {
|
||||
toast.success(`Deleted the codes!`);
|
||||
setSelectedCodes([]);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const deleteCode = async (code: Code) => {
|
||||
if (!canDeleteCodes) return
|
||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
|
||||
const deleteCode = async (code: Code) => {
|
||||
if (!canDeleteCodes) return;
|
||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
|
||||
return;
|
||||
|
||||
axios
|
||||
.delete(`/api/code/${code.code}`)
|
||||
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/code/${code.code}`)
|
||||
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("code", {
|
||||
id: "codeCheckbox",
|
||||
enableSorting: false,
|
||||
header: () => (""),
|
||||
cell: (info) =>
|
||||
!info.row.original.userId ? (
|
||||
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
|
||||
{""}
|
||||
</Checkbox>
|
||||
) : null,
|
||||
}),
|
||||
columnHelper.accessor("code", {
|
||||
header: "Code",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("creationDate", {
|
||||
header: "Creation Date",
|
||||
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("creator", {
|
||||
header: "Creator",
|
||||
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entity", {
|
||||
header: "Entity",
|
||||
cell: (info) => info.getValue()?.label || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
header: "Availability",
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<span className="flex gap-1 items-center text-mti-green">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center text-mti-red">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Code } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{canDeleteCodes && !row.original.userId && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("code", {
|
||||
id: "codeCheckbox",
|
||||
enableSorting: false,
|
||||
header: () => "",
|
||||
cell: (info) =>
|
||||
!info.row.original.userId ? (
|
||||
<Checkbox
|
||||
isChecked={selectedCodes.includes(info.getValue())}
|
||||
onChange={() => toggleCode(info.getValue())}
|
||||
>
|
||||
{""}
|
||||
</Checkbox>
|
||||
) : null,
|
||||
}),
|
||||
columnHelper.accessor("code", {
|
||||
header: "Code",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("creationDate", {
|
||||
header: "Creation Date",
|
||||
cell: (info) =>
|
||||
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("creator", {
|
||||
header: "Creator",
|
||||
cell: (info) =>
|
||||
info.getValue()
|
||||
? `${info.getValue().name} (${
|
||||
USER_TYPE_LABELS[info.getValue().type]
|
||||
})`
|
||||
: "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entity", {
|
||||
header: "Entity",
|
||||
cell: (info) => info.getValue()?.label || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
header: "Availability",
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<span className="flex gap-1 items-center text-mti-green">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center text-mti-red">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Code } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{canDeleteCodes && !row.original.userId && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deleteCode(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-4 pt-1">
|
||||
{canDeleteCodes && (
|
||||
<div className="flex gap-4 items-center w-full justify-end">
|
||||
<span>{selectedCodes.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedCodes.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteCodes(selectedCodes)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table<TableData>
|
||||
data={data}
|
||||
columns={defaultColumns}
|
||||
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-4 pt-1">
|
||||
{canDeleteCodes && (
|
||||
<div className="flex gap-4 items-center w-full justify-end">
|
||||
<span>{selectedCodes.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedCodes.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteCodes(selectedCodes)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table<TableData>
|
||||
data={data}
|
||||
columns={defaultColumns}
|
||||
isLoading={isLoading}
|
||||
searchFields={[
|
||||
["code"],
|
||||
["email"],
|
||||
["entity", "label"],
|
||||
["creator", "name"],
|
||||
["creator", "type"],
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,346 +4,391 @@ import useExams from "@/hooks/useExams";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { capitalize, uniq } from "lodash";
|
||||
import { capitalize } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX } from "react-icons/bs";
|
||||
import { BsPencil, BsTrash, BsUpload } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import Modal from "@/components/Modal";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import Button from "@/components/Low/Button";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { FiEdit, FiArrowRight } from 'react-icons/fi';
|
||||
import { HiArrowRight } from "react-icons/hi";
|
||||
import { BiEdit } from "react-icons/bi";
|
||||
import { findBy, mapBy } from "@/utils";
|
||||
|
||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||
|
||||
const CLASSES: { [key in Module]: string } = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Exam>();
|
||||
|
||||
const ExamOwnerSelector = ({ options, exam, onSave }: { options: User[]; exam: Exam; onSave: (owners: string[]) => void }) => {
|
||||
const [owners, setOwners] = useState(exam.owners || []);
|
||||
export default function ExamList({
|
||||
user,
|
||||
entities,
|
||||
}: {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
}) {
|
||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="grid grid-cols-4 mt-4">
|
||||
{options.map((c) => (
|
||||
<Button
|
||||
variant={owners.includes(c.id) ? "solid" : "outline"}
|
||||
onClick={() => setOwners((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]))}
|
||||
className="max-w-[200px] w-full"
|
||||
key={c.id}>
|
||||
{c.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => onSave(owners)} className="w-full max-w-[200px] self-end">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const canViewConfidentialEntities = useMemo(
|
||||
() =>
|
||||
mapBy(
|
||||
findAllowedEntities(user, entities, "view_confidential_exams"),
|
||||
"id"
|
||||
),
|
||||
[user, entities]
|
||||
);
|
||||
|
||||
export default function ExamList({ user, entities }: { user: User; entities: EntityWithRoles[]; }) {
|
||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||
const { exams, reload, isLoading } = useExams();
|
||||
const { users } = useUsers();
|
||||
// Pass this permission filter to the backend later
|
||||
const filteredExams = useMemo(
|
||||
() =>
|
||||
["admin", "developer"].includes(user?.type)
|
||||
? exams
|
||||
: exams.filter((item) => {
|
||||
if (
|
||||
item.access === "confidential" &&
|
||||
!canViewConfidentialEntities.find((x) =>
|
||||
(item.entities ?? []).includes(x)
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
[canViewConfidentialEntities, exams, user?.type]
|
||||
);
|
||||
|
||||
const { exams, reload } = useExams();
|
||||
const { users } = useUsers();
|
||||
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
|
||||
const parsedExams = useMemo(() => {
|
||||
return filteredExams.map((exam) => {
|
||||
if (exam.createdBy) {
|
||||
const user = users.find((u) => u.id === exam.createdBy);
|
||||
if (!user) return exam;
|
||||
|
||||
const filteredExams = useMemo(() => exams.filter((e) => {
|
||||
if (!e.private) return true
|
||||
return (e.owners || []).includes(user?.id || "")
|
||||
}), [exams, user?.id])
|
||||
return {
|
||||
...exam,
|
||||
createdBy: user.type === "developer" ? "system" : user.name,
|
||||
};
|
||||
}
|
||||
|
||||
const filteredCorporates = useMemo(() => {
|
||||
const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id);
|
||||
return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate");
|
||||
}, [users, groups, user]);
|
||||
return exam;
|
||||
});
|
||||
}, [filteredExams, users]);
|
||||
|
||||
const parsedExams = useMemo(() => {
|
||||
return filteredExams.map((exam) => {
|
||||
if (exam.createdBy) {
|
||||
const user = users.find((u) => u.id === exam.createdBy);
|
||||
if (!user) return exam;
|
||||
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
|
||||
searchFields,
|
||||
parsedExams
|
||||
);
|
||||
|
||||
return {
|
||||
...exam,
|
||||
createdBy: user.type === "developer" ? "system" : user.name,
|
||||
};
|
||||
}
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
return exam;
|
||||
});
|
||||
}, [filteredExams, users]);
|
||||
const router = useRouter();
|
||||
|
||||
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(searchFields, parsedExams);
|
||||
const loadExam = async (module: Module, examId: string) => {
|
||||
const exam = await getExamById(module, 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",
|
||||
}
|
||||
);
|
||||
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: { exams: [exam], modules: [module] },
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
router.push("/exam");
|
||||
};
|
||||
|
||||
const loadExam = async (module: Module, examId: string) => {
|
||||
const exam = await getExamById(module, 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",
|
||||
});
|
||||
/*
|
||||
const privatizeExam = async (exam: Exam) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
|
||||
exam.access
|
||||
}?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "INIT_EXAM", payload: { exams: [exam], modules: [module] } })
|
||||
axios
|
||||
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/exam");
|
||||
};
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to update this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
const privatizeExam = async (exam: Exam) => {
|
||||
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
*/
|
||||
|
||||
axios
|
||||
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
const deleteExam = async (exam: Exam) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to update this exam!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateExam = async (exam: Exam, body: object) => {
|
||||
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
axios
|
||||
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
|
||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
const getTotalExercises = (exam: Exam) => {
|
||||
if (
|
||||
exam.module === "reading" ||
|
||||
exam.module === "listening" ||
|
||||
exam.module === "level"
|
||||
) {
|
||||
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to update this exam!");
|
||||
return;
|
||||
}
|
||||
return countExercises(exam.exercises);
|
||||
};
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload)
|
||||
.finally(() => setSelectedExam(undefined));
|
||||
};
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("module", {
|
||||
header: "Module",
|
||||
cell: (info) => (
|
||||
<span className={CLASSES[info.getValue()]}>
|
||||
{capitalize(info.getValue())}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||
header: "Exercises",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("minTimer", {
|
||||
header: "Timer",
|
||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||
}),
|
||||
columnHelper.accessor("access", {
|
||||
header: "Access",
|
||||
cell: (info) => <span>{capitalize(info.getValue())}</span>,
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
header: "Created At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (value) {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
const deleteExam = async (exam: Exam) => {
|
||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdBy", {
|
||||
header: "Created By",
|
||||
cell: (info) =>
|
||||
!info.getValue()
|
||||
? "System"
|
||||
: findBy(users, "id", info.getValue())?.name || "N/A",
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Exam } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{(row.original.owners?.includes(user.id) ||
|
||||
checkAccess(user, ["admin", "developer"])) && (
|
||||
<>
|
||||
{checkAccess(user, [
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<button
|
||||
data-tip="Edit exam"
|
||||
onClick={() => setSelectedExam(row.original)}
|
||||
className="cursor-pointer tooltip"
|
||||
>
|
||||
<BsPencil />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
data-tip="Load exam"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={async () =>
|
||||
await loadExam(row.original.module, row.original.id)
|
||||
}
|
||||
>
|
||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deleteExam(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
axios
|
||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
const table = useReactTable({
|
||||
data: filteredRows,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
const handleExamEdit = () => {
|
||||
router.push(
|
||||
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
|
||||
);
|
||||
};
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
{renderSearch()}
|
||||
<Modal
|
||||
isOpen={!!selectedExam}
|
||||
onClose={() => setSelectedExam(undefined)}
|
||||
maxWidth="max-w-xl"
|
||||
>
|
||||
{!!selectedExam ? (
|
||||
<>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BiEdit className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-gray-600 font-medium">
|
||||
Ready to Edit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
const getTotalExercises = (exam: Exam) => {
|
||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||
}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
||||
</div>
|
||||
|
||||
return countExercises(exam.exercises);
|
||||
};
|
||||
<p className="text-gray-500 text-sm">
|
||||
Click 'Next' to proceed to the exam editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("module", {
|
||||
header: "Module",
|
||||
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
||||
}),
|
||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||
header: "Exercises",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("minTimer", {
|
||||
header: "Timer",
|
||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||
}),
|
||||
columnHelper.accessor("private", {
|
||||
header: "Private",
|
||||
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
header: "Created At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (value) {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdBy", {
|
||||
header: "Created By",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Exam } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
||||
<>
|
||||
<button
|
||||
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
||||
onClick={async () => await privatizeExam(row.original)}
|
||||
className="cursor-pointer tooltip">
|
||||
{row.original.private ? <BsCircle /> : <BsBan />}
|
||||
</button>
|
||||
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
|
||||
<button data-tip="Edit exam" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
|
||||
<BsPencil />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
data-tip="Load exam"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredRows,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const handleExamEdit = () => {
|
||||
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
{renderSearch()}
|
||||
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl">
|
||||
{!!selectedExam ? (
|
||||
<>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BiEdit className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-gray-600 font-medium">Ready to Edit</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||
<p className="font-medium mb-1">
|
||||
Exam ID: {selectedExam.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm">
|
||||
Click 'Next' to proceed to the exam editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-4 mt-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedExam(undefined)}
|
||||
className="w-32"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={handleExamEdit}
|
||||
className="w-32 text-white flex items-center justify-center gap-2"
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Modal>
|
||||
<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="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
<div className="flex justify-between gap-4 mt-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedExam(undefined)}
|
||||
className="w-32"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={handleExamEdit}
|
||||
className="w-32 text-white flex items-center justify-center gap-2"
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Modal>
|
||||
<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="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading ? (
|
||||
<div className="min-h-screen flex justify-center items-start">
|
||||
<span className="loading loading-infinity w-32" />
|
||||
</div>
|
||||
) : (
|
||||
filteredRows.length === 0 && (
|
||||
<div className="w-full flex justify-center items-start">
|
||||
<span className="text-xl text-gray-500">No data found...</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { CorporateUser, Group, User } from "@/interfaces/user";
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { Group, User } from "@/interfaces/user";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import { capitalize, uniq } from "lodash";
|
||||
import { uniq } from "lodash";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||
import Select from "react-select";
|
||||
import { toast } from "react-toastify";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
import { getUserCorporate } from "@/utils/groups";
|
||||
import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import Table from "@/components/High/Table";
|
||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||
import { WithEntity } from "@/interfaces/entity";
|
||||
|
||||
const searchFields = [["name"]];
|
||||
|
||||
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
const EMAIL_REGEX = new RegExp(
|
||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||
);
|
||||
|
||||
interface CreateDialogProps {
|
||||
user: User;
|
||||
@@ -35,9 +34,13 @@ interface CreateDialogProps {
|
||||
}
|
||||
|
||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||
const [name, setName] = useState<string | undefined>(
|
||||
group?.name || undefined
|
||||
);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||
const [participants, setParticipants] = useState<string[]>(
|
||||
group?.participants || []
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
@@ -47,9 +50,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
});
|
||||
|
||||
const availableUsers = useMemo(() => {
|
||||
if (user?.type === "teacher") return users.filter((x) => ["student"].includes(x.type));
|
||||
if (user?.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||
if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type));
|
||||
if (user?.type === "teacher")
|
||||
return users.filter((x) => ["student"].includes(x.type));
|
||||
if (user?.type === "corporate")
|
||||
return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||
if (user?.type === "mastercorporate")
|
||||
return users.filter((x) =>
|
||||
["corporate", "teacher", "student"].includes(x.type)
|
||||
);
|
||||
|
||||
return users;
|
||||
}, [user, users]);
|
||||
@@ -64,9 +72,12 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
rows
|
||||
.map((row) => {
|
||||
const [email] = row as string[];
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||
return EMAIL_REGEX.test(email) &&
|
||||
!users.map((u) => u.email).includes(email)
|
||||
? email.toString().trim()
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
.filter((x) => !!x)
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
@@ -76,12 +87,17 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||
const emailUsers = [...new Set(emails)]
|
||||
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||
.filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
|
||||
((user.type === "developer" ||
|
||||
user.type === "admin" ||
|
||||
user.type === "corporate" ||
|
||||
user.type === "mastercorporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
(user.type === "teacher" && x?.type === "student")
|
||||
);
|
||||
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
@@ -89,7 +105,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{ toastId: "upload-success" },
|
||||
{ toastId: "upload-success" }
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
@@ -100,15 +116,27 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
const submit = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
if (
|
||||
name !== group?.name &&
|
||||
(name?.trim() === "Students" ||
|
||||
name?.trim() === "Teachers" ||
|
||||
name?.trim() === "Corporate")
|
||||
) {
|
||||
toast.error(
|
||||
"That group name is reserved and cannot be used, please enter another one."
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants })
|
||||
(group ? axios.patch : axios.post)(
|
||||
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||
{ name, admin, participants }
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||
toast.success(
|
||||
`Group "${name}" ${group ? "edited" : "created"} successfully`
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -121,30 +149,58 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const userOptions = useMemo(
|
||||
() =>
|
||||
availableUsers.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.email} - ${x.name}`,
|
||||
})),
|
||||
[availableUsers]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() =>
|
||||
participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${
|
||||
users.find((y) => y.id === x)?.name
|
||||
}`,
|
||||
})),
|
||||
[participants, users]
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||
<Input
|
||||
name="name"
|
||||
type="text"
|
||||
label="Name"
|
||||
defaultValue={name}
|
||||
onChange={setName}
|
||||
required
|
||||
disabled={group?.disableEditing}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Participants
|
||||
</label>
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-8">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
value={value}
|
||||
placeholder="Participants..."
|
||||
defaultValue={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
options={availableUsers.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
||||
defaultValue={value}
|
||||
options={userOptions}
|
||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||
isMulti
|
||||
isSearchable
|
||||
@@ -160,18 +216,36 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
}}
|
||||
/>
|
||||
{user.type !== "teacher" && (
|
||||
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||
<Button
|
||||
className="w-full max-w-[300px] h-fit"
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{filesContent.length === 0
|
||||
? "Upload participants Excel file"
|
||||
: filesContent[0].name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
isLoading={isLoading}
|
||||
disabled={!name}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
@@ -182,7 +256,8 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
export default function GroupList({ user }: { user: User }) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
||||
const [viewingAllParticipants, setViewingAllParticipants] =
|
||||
useState<string>();
|
||||
|
||||
const { permissions } = usePermissions(user?.id || "");
|
||||
|
||||
@@ -211,7 +286,14 @@ export default function GroupList({ user }: { user: User }) {
|
||||
columnHelper.accessor("admin", {
|
||||
header: "Admin",
|
||||
cell: (info) => (
|
||||
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip={
|
||||
USER_TYPE_LABELS[
|
||||
users.find((x) => x.id === info.getValue())?.type || "student"
|
||||
]
|
||||
}
|
||||
>
|
||||
{users.find((x) => x.id === info.getValue())?.name}
|
||||
</div>
|
||||
),
|
||||
@@ -226,23 +308,30 @@ export default function GroupList({ user }: { user: User }) {
|
||||
<span>
|
||||
{info
|
||||
.getValue()
|
||||
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
||||
.slice(
|
||||
0,
|
||||
viewingAllParticipants === info.row.original.id ? undefined : 5
|
||||
)
|
||||
.map((x) => users.find((y) => y.id === x)?.name)
|
||||
.join(", ")}
|
||||
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
|
||||
<button
|
||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
onClick={() => setViewingAllParticipants(info.row.original.id)}>
|
||||
, View More
|
||||
</button>
|
||||
)}
|
||||
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
|
||||
<button
|
||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
onClick={() => setViewingAllParticipants(undefined)}>
|
||||
, View Less
|
||||
</button>
|
||||
)}
|
||||
{info.getValue().length > 5 &&
|
||||
viewingAllParticipants !== info.row.original.id && (
|
||||
<button
|
||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
onClick={() => setViewingAllParticipants(info.row.original.id)}
|
||||
>
|
||||
, View More
|
||||
</button>
|
||||
)}
|
||||
{info.getValue().length > 5 &&
|
||||
viewingAllParticipants === info.row.original.id && (
|
||||
<button
|
||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
onClick={() => setViewingAllParticipants(undefined)}
|
||||
>
|
||||
, View Less
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
@@ -252,20 +341,34 @@ export default function GroupList({ user }: { user: User }) {
|
||||
cell: ({ row }: { row: { original: Group } }) => {
|
||||
return (
|
||||
<>
|
||||
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
|
||||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
|
||||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{user &&
|
||||
(checkAccess(user, ["developer", "admin"]) ||
|
||||
user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{(!row.original.disableEditing ||
|
||||
checkAccess(user, ["developer", "admin"]),
|
||||
"editGroup") && (
|
||||
<div
|
||||
data-tip="Edit"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => setEditingGroup(row.original)}
|
||||
>
|
||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
{(!row.original.disableEditing ||
|
||||
checkAccess(user, ["developer", "admin"]),
|
||||
"deleteGroup") && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => deleteGroup(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -280,7 +383,11 @@ export default function GroupList({ user }: { user: User }) {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingGroup}
|
||||
onClose={closeModal}
|
||||
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||
>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
@@ -288,12 +395,22 @@ export default function GroupList({ user }: { user: User }) {
|
||||
users={users}
|
||||
/>
|
||||
</Modal>
|
||||
<Table data={groups} columns={defaultColumns} searchFields={searchFields} />
|
||||
<Table
|
||||
data={groups}
|
||||
columns={defaultColumns}
|
||||
searchFields={searchFields}
|
||||
/>
|
||||
|
||||
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
|
||||
{checkAccess(
|
||||
user,
|
||||
["teacher", "corporate", "mastercorporate", "admin", "developer"],
|
||||
permissions,
|
||||
"createGroup"
|
||||
) && (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||
>
|
||||
New Group
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,256 +1,341 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import usePackages from "@/hooks/usePackages";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Package} from "@/interfaces/paypal";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Package } from "@/interfaces/paypal";
|
||||
import { User } from "@/interfaces/user";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import {capitalize} from "lodash";
|
||||
import {useState} from "react";
|
||||
import {BsPencil, BsTrash} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import { capitalize } from "lodash";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import Select from "react-select";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
import { CURRENCIES } from "@/resources/paypal";
|
||||
import Button from "@/components/Low/Button";
|
||||
|
||||
const CLASSES: {[key in Module]: string} = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
const CLASSES: { [key in Module]: string } = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Package>();
|
||||
|
||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||
|
||||
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
|
||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
||||
const currencyOptions = CURRENCIES.map(({ label, currency }) => ({
|
||||
value: currency,
|
||||
label,
|
||||
}));
|
||||
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||
function PackageCreator({
|
||||
pack,
|
||||
onClose,
|
||||
}: {
|
||||
pack?: Package;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||
const [unit, setUnit] = useState<DurationUnit>(
|
||||
pack?.duration_unit || "months"
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
||||
duration,
|
||||
duration_unit: unit,
|
||||
price,
|
||||
currency,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("New payment has been created successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
};
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
||||
const submit = useCallback(() => {
|
||||
(pack ? axios.patch : axios.post)(
|
||||
pack ? `/api/packages/${pack.id}` : "/api/packages",
|
||||
{
|
||||
duration,
|
||||
duration_unit: unit,
|
||||
price,
|
||||
currency,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
toast.success("New payment has been created successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
}, [duration, unit, price, currency, pack, onClose]);
|
||||
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
{value: "days", label: "Days"},
|
||||
{value: "weeks", label: "Weeks"},
|
||||
{value: "months", label: "Months"},
|
||||
{value: "years", label: "Years"},
|
||||
]}
|
||||
defaultValue={{value: "months", label: "Months"}}
|
||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
||||
value={{value: unit, label: capitalize(unit)}}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const currencyDefaultValue = useMemo(() => {
|
||||
return {
|
||||
value: currency || "EUR",
|
||||
label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
|
||||
};
|
||||
}, [currency]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Price *
|
||||
</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
defaultValue={price}
|
||||
name="price"
|
||||
type="number"
|
||||
onChange={(e) => setPrice(parseInt(e))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={currencyOptions}
|
||||
defaultValue={currencyDefaultValue}
|
||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||
value={currencyDefaultValue}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Duration *
|
||||
</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
defaultValue={duration}
|
||||
name="duration"
|
||||
type="number"
|
||||
onChange={(e) => setDuration(parseInt(e))}
|
||||
/>
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
{ value: "days", label: "Days" },
|
||||
{ value: "weeks", label: "Weeks" },
|
||||
{ value: "months", label: "Months" },
|
||||
{ value: "years", label: "Years" },
|
||||
]}
|
||||
defaultValue={{ value: "months", label: "Months" }}
|
||||
onChange={(value) =>
|
||||
setUnit((value?.value as DurationUnit) || "months")
|
||||
}
|
||||
value={{ value: unit, label: capitalize(unit) }}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
disabled={!duration || !price}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackageList({user}: {user: User}) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||
export default function PackageList({ user }: { user: User }) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||
|
||||
const {packages, reload} = usePackages();
|
||||
const { packages, reload } = usePackages();
|
||||
|
||||
const deletePackage = async (pack: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||
const deletePackage = useCallback(
|
||||
async (pack: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/packages/${pack.id}`)
|
||||
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Package not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/packages/${pack.id}`)
|
||||
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Package not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("duration", {
|
||||
header: "Duration",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.duration_unit}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("price", {
|
||||
header: "Price",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.currency}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Package}}) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("duration", {
|
||||
header: "Duration",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.duration_unit}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("price", {
|
||||
header: "Price",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.currency}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Package } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{["developer", "admin"].includes(user?.type) && (
|
||||
<div
|
||||
data-tip="Edit"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => setEditingPackage(row.original)}
|
||||
>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
{["developer", "admin"].includes(user?.type) && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deletePackage(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[deletePackage, user]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: packages,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: packages,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingPackage(undefined);
|
||||
reload();
|
||||
};
|
||||
const closeModal = useCallback(() => {
|
||||
setIsCreating(false);
|
||||
setEditingPackage(undefined);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingPackage}
|
||||
onClose={closeModal}
|
||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
|
||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
New Package
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingPackage}
|
||||
onClose={closeModal}
|
||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
|
||||
>
|
||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
|
||||
>
|
||||
New Package
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +1,158 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {Stat, StudentUser, User} from "@/interfaces/user";
|
||||
import {useState} from "react";
|
||||
import {averageLevelCalculator} from "@/utils/score";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import {createColumnHelper} from "@tanstack/react-table";
|
||||
import { Stat, StudentUser, User } from "@/interfaces/user";
|
||||
import { useState } from "react";
|
||||
import { averageLevelCalculator } from "@/utils/score";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import List from "@/components/List";
|
||||
import Table from "@/components/High/Table";
|
||||
|
||||
type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string};
|
||||
type StudentPerformanceItem = StudentUser & {
|
||||
entitiesLabel: string;
|
||||
group: string;
|
||||
userStats: Stat[];
|
||||
};
|
||||
|
||||
const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceItem[]; stats: Stat[]}) => {
|
||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||
const StudentPerformanceList = ({
|
||||
items = [],
|
||||
}: {
|
||||
items: StudentPerformanceItem[];
|
||||
}) => {
|
||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||
|
||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Student Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("studentID", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("group", {
|
||||
header: "Group",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("entitiesLabel", {
|
||||
header: "Entities",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("levels.reading", {
|
||||
header: "Reading",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.listening", {
|
||||
header: "Listening",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.writing", {
|
||||
header: "Writing",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.speaking", {
|
||||
header: "Speaking",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.level", {
|
||||
header: "Level",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels", {
|
||||
id: "overall_level",
|
||||
header: "Overall",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? averageLevelCalculator(
|
||||
items,
|
||||
stats.filter((x) => x.user === info.row.original.id),
|
||||
).toFixed(1)
|
||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||
}),
|
||||
];
|
||||
const columns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Student Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("studentID", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("group", {
|
||||
header: "Group",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("entitiesLabel", {
|
||||
header: "Entities",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("levels.reading", {
|
||||
header: "Reading",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "reading"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.listening", {
|
||||
header: "Listening",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "listening"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.writing", {
|
||||
header: "Writing",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "writing"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.speaking", {
|
||||
header: "Speaking",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "speaking"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("levels.level", {
|
||||
header: "Level",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? info.getValue() || 0
|
||||
: `${
|
||||
Object.keys(
|
||||
groupByExam(
|
||||
info.row.original.userStats.filter(
|
||||
(x) => x.module === "level"
|
||||
)
|
||||
)
|
||||
).length
|
||||
} exams`,
|
||||
}),
|
||||
columnHelper.accessor("userStats", {
|
||||
id: "overall_level",
|
||||
header: "Overall",
|
||||
cell: (info) =>
|
||||
!isShowingAmount
|
||||
? averageLevelCalculator(
|
||||
info.row.original.focus,
|
||||
info.getValue()
|
||||
).toFixed(1)
|
||||
: `${Object.keys(groupByExam(info.getValue())).length} exams`,
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||
Show Utilization
|
||||
</Checkbox>
|
||||
<Table<StudentPerformanceItem>
|
||||
data={items.sort(
|
||||
(a, b) =>
|
||||
averageLevelCalculator(
|
||||
items,
|
||||
stats.filter((x) => x.user === b.id),
|
||||
) -
|
||||
averageLevelCalculator(
|
||||
items,
|
||||
stats.filter((x) => x.user === a.id),
|
||||
),
|
||||
)}
|
||||
columns={columns}
|
||||
searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||
Show Utilization
|
||||
</Checkbox>
|
||||
<Table<StudentPerformanceItem>
|
||||
data={items.sort(
|
||||
(a, b) =>
|
||||
averageLevelCalculator(b.focus, b.userStats) -
|
||||
averageLevelCalculator(a.focus, a.userStats)
|
||||
)}
|
||||
columns={columns}
|
||||
searchFields={[
|
||||
["name"],
|
||||
["email"],
|
||||
["studentID"],
|
||||
["entitiesLabel"],
|
||||
["group"],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentPerformanceList;
|
||||
|
||||
@@ -5,8 +5,13 @@ import axios from "axios";
|
||||
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 { useMemo, useState } from "react";
|
||||
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";
|
||||
@@ -17,433 +22,600 @@ import useFilterStore from "@/stores/listFilterStore";
|
||||
import { useRouter } from "next/router";
|
||||
import { mapBy } from "@/utils";
|
||||
import { exportListToExcel } from "@/utils/users";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import useUserBalance from "@/hooks/useUserBalance";
|
||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||
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 = async (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;
|
||||
const csv = await 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: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = "users.xlsx";
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import clsx from "clsx";
|
||||
import CodeList from "./CodeList";
|
||||
import DiscountList from "./DiscountList";
|
||||
import ExamList from "./ExamList";
|
||||
import GroupList from "./GroupList";
|
||||
import PackageList from "./PackageList";
|
||||
import UserList from "./UserList";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { CorporateUser, TeacherUser, Type, User } from "@/interfaces/user";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import Input from "@/components/Low/Input";
|
||||
import CountrySelect from "@/components/Low/CountrySelect";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { getUserName } from "@/utils/users";
|
||||
import Select from "@/components/Low/Select";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||
@@ -48,23 +41,44 @@ const USER_TYPE_PERMISSIONS: {
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
entities: EntityWithRoles[]
|
||||
entities: EntityWithRoles[];
|
||||
permissions: PermissionType[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function UserCreator({ user, users, entities = [], permissions, onFinish }: Props) {
|
||||
export default function UserCreator({
|
||||
user,
|
||||
users,
|
||||
entities = [],
|
||||
permissions,
|
||||
onFinish,
|
||||
}: Props) {
|
||||
const [name, setName] = useState<string>();
|
||||
const [email, setEmail] = useState<string>();
|
||||
const [phone, setPhone] = useState<string>();
|
||||
@@ -75,13 +89,15 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
const [password, setPassword] = useState<string>();
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>();
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user?.subscriptionExpirationDate).toDate()
|
||||
: null
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [position, setPosition] = useState<string>();
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||
|
||||
const { groups } = useEntitiesGroups();
|
||||
|
||||
@@ -90,11 +106,16 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const createUser = () => {
|
||||
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
|
||||
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
|
||||
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
|
||||
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
|
||||
if (password !== confirmPassword) return toast.error("The passwords do not match!");
|
||||
if (!name || name.trim().length === 0)
|
||||
return toast.error("Please enter a valid name!");
|
||||
if (!email || email.trim().length === 0)
|
||||
return toast.error("Please enter a valid e-mail address!");
|
||||
if (users.map((x) => x.email).includes(email.trim()))
|
||||
return toast.error("That e-mail is already in use!");
|
||||
if (!password || password.trim().length < 6)
|
||||
return toast.error("Please enter a valid password!");
|
||||
if (password !== confirmPassword)
|
||||
return toast.error("The passwords do not match!");
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -128,8 +149,12 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
setStudentID("");
|
||||
setCountry(user?.demographicInformation?.country);
|
||||
setGroup(null);
|
||||
setEntity((entities || [])[0]?.id || undefined)
|
||||
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
|
||||
setEntity((entities || [])[0]?.id || undefined);
|
||||
setExpiryDate(
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user?.subscriptionExpirationDate).toDate()
|
||||
: null
|
||||
);
|
||||
setIsExpiryDateEnabled(true);
|
||||
setType("student");
|
||||
setPosition(undefined);
|
||||
@@ -145,10 +170,34 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
|
||||
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
|
||||
<Input
|
||||
required
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<Input
|
||||
label="E-mail"
|
||||
required
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="E-mail"
|
||||
/>
|
||||
|
||||
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
@@ -160,11 +209,21 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Country *
|
||||
</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</div>
|
||||
|
||||
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
label="Phone number"
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
placeholder="Phone number"
|
||||
required
|
||||
/>
|
||||
|
||||
{type === "student" && (
|
||||
<>
|
||||
@@ -177,14 +236,26 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
placeholder="National ID or Passport number"
|
||||
required
|
||||
/>
|
||||
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
|
||||
<Input
|
||||
type="text"
|
||||
name="studentID"
|
||||
label="Student ID"
|
||||
onChange={setStudentID}
|
||||
value={studentID}
|
||||
placeholder="Student ID"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Entity
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||
defaultValue={{
|
||||
value: (entities || [])[0]?.id,
|
||||
label: (entities || [])[0]?.label,
|
||||
}}
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
onChange={(e) => setEntity(e?.value || undefined)}
|
||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||
@@ -192,11 +263,20 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
</div>
|
||||
|
||||
{["corporate", "mastercorporate"].includes(type) && (
|
||||
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
||||
<Input
|
||||
type="text"
|
||||
name="department"
|
||||
label="Department"
|
||||
onChange={setPosition}
|
||||
value={position}
|
||||
placeholder="Department"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={clsx("flex flex-col gap-4")}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Classroom
|
||||
</label>
|
||||
<Select
|
||||
options={groups
|
||||
.filter((x) => x.entity?.id === entity)
|
||||
@@ -209,63 +289,85 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-4",
|
||||
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
|
||||
)}>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
!checkAccess(user, [
|
||||
"developer",
|
||||
"admin",
|
||||
"corporate",
|
||||
"mastercorporate",
|
||||
]) && "col-span-2"
|
||||
)}
|
||||
>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Type
|
||||
</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as Type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||
})
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||
acc.push(x);
|
||||
return acc;
|
||||
}, [])}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user?.subscriptionExpirationDate}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{user &&
|
||||
checkAccess(user, [
|
||||
"developer",
|
||||
"admin",
|
||||
"corporate",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user?.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user?.subscriptionExpirationDate
|
||||
? moment(date).isBefore(
|
||||
user?.subscriptionExpirationDate
|
||||
)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
|
||||
<Button
|
||||
onClick={createUser}
|
||||
isLoading={isLoading}
|
||||
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</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]);
|
||||
*/
|
||||
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.find(
|
||||
(c: any) => c && 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,
|
||||
]);
|
||||
|
||||
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.finalizeExam && moduleIndex !== -1 && !moduleLock) {
|
||||
(async () => {
|
||||
setModuleIndex(-1);
|
||||
await saveStats();
|
||||
await axios.get("/api/stats/update");
|
||||
})();
|
||||
}
|
||||
}, [
|
||||
flags.finalizeExam,
|
||||
moduleIndex,
|
||||
saveStats,
|
||||
setModuleIndex,
|
||||
userSolutions,
|
||||
moduleLock,
|
||||
flags.finalizeModule,
|
||||
]);
|
||||
|
||||
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 &&
|
||||
!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]);
|
||||
|
||||
useEvaluationPolling({ pendingExercises, setPendingExercises });
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (flags.finalizeExam && moduleIndex !== -1) {
|
||||
setModuleIndex(-1);
|
||||
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);
|
||||
|
||||
scores[examModule!] = {
|
||||
total: scores[examModule!].total + x.score.total,
|
||||
correct: scores[examModule!].correct + x.score.correct,
|
||||
missing: scores[examModule!].missing + x.score.missing,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}, [flags.finalizeExam, moduleIndex, setModuleIndex]);
|
||||
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;
|
||||
}, []);
|
||||
};
|
||||
|
||||
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]);
|
||||
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 CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
|
||||
|
||||
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 onAbandon = async () => {
|
||||
await saveSession();
|
||||
reset();
|
||||
};
|
||||
|
||||
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 {
|
||||
setBgColor,
|
||||
setHideSidebar,
|
||||
setFocusMode,
|
||||
setOnFocusLayerMouseEnter,
|
||||
} = React.useContext(LayoutContext);
|
||||
|
||||
scores[examModule!] = {
|
||||
total: scores[examModule!].total + x.score.total,
|
||||
correct: scores[examModule!].correct + x.score.correct,
|
||||
missing: scores[examModule!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||
};
|
||||
useEffect(() => {
|
||||
setBgColor(bgColor);
|
||||
setHideSidebar(hideSidebar);
|
||||
setFocusMode(
|
||||
selectedModules.length !== 0 &&
|
||||
!showSolutions &&
|
||||
moduleIndex < selectedModules.length
|
||||
);
|
||||
}, [
|
||||
bgColor,
|
||||
hideSidebar,
|
||||
moduleIndex,
|
||||
selectedModules.length,
|
||||
setBgColor,
|
||||
setFocusMode,
|
||||
setHideSidebar,
|
||||
showSolutions,
|
||||
]);
|
||||
|
||||
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 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,267 +13,268 @@ import moment from "moment";
|
||||
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
}
|
||||
|
||||
const availableDurations = {
|
||||
"1_month": { label: "1 Month", number: 1 },
|
||||
"3_months": { label: "3 Months", number: 3 },
|
||||
"6_months": { label: "6 Months", number: 6 },
|
||||
"12_months": { label: "12 Months", number: 12 },
|
||||
"1_month": { label: "1 Month", number: 1 },
|
||||
"3_months": { label: "3 Months", number: 3 },
|
||||
"6_months": { label: "6 Months", number: 6 },
|
||||
"12_months": { label: "12 Months", number: 12 },
|
||||
};
|
||||
|
||||
export default function RegisterCorporate({
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
mutateUser,
|
||||
sendEmailVerification,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
mutateUser,
|
||||
sendEmailVerification,
|
||||
}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
||||
|
||||
const { users } = useUsers();
|
||||
const { users } = useUsers({ type: "agent" });
|
||||
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
"An e-mail has been sent, please make sure to check your spam folder!",
|
||||
);
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
"An e-mail has been sent, please make sure to check your spam folder!"
|
||||
);
|
||||
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {
|
||||
toastId: "send-verify-error",
|
||||
});
|
||||
};
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {
|
||||
toastId: "send-verify-error",
|
||||
});
|
||||
};
|
||||
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {
|
||||
toastId: "password-not-match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {
|
||||
toastId: "password-not-match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "corporate",
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||
corporateInformation: {
|
||||
monthlyDuration: subscriptionDuration,
|
||||
referralAgent,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() =>
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "corporate",
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||
corporateInformation: {
|
||||
monthlyDuration: subscriptionDuration,
|
||||
referralAgent,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() =>
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-4"
|
||||
onSubmit={register}
|
||||
>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={(e) => setName(e)}
|
||||
placeholder="Enter your name"
|
||||
defaultValue={name}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e.toLowerCase())}
|
||||
placeholder="Enter email address"
|
||||
defaultValue={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-4"
|
||||
onSubmit={register}
|
||||
>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={(e) => setName(e)}
|
||||
placeholder="Enter your name"
|
||||
defaultValue={name}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e.toLowerCase())}
|
||||
placeholder="Enter email address"
|
||||
defaultValue={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider className="!my-2 w-full" />
|
||||
<Divider className="!my-2 w-full" />
|
||||
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Corporate name"
|
||||
label="Corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
label="Number of users"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Corporate name"
|
||||
label="Corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
label="Number of users"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Referral *
|
||||
</label>
|
||||
<Select
|
||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||
options={[
|
||||
{ value: "", label: "No referral" },
|
||||
...users
|
||||
.filter((u) => u.type === "agent")
|
||||
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
|
||||
]}
|
||||
defaultValue={{ value: "", label: "No referral" }}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Referral *
|
||||
</label>
|
||||
<Select
|
||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||
options={[
|
||||
{ value: "", label: "No referral" },
|
||||
...users.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
})),
|
||||
]}
|
||||
defaultValue={{ value: "", label: "No referral" }}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Subscription Duration *
|
||||
</label>
|
||||
<Select
|
||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||
options={Object.keys(availableDurations).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
availableDurations[value as keyof typeof availableDurations]
|
||||
.label,
|
||||
}))}
|
||||
defaultValue={{
|
||||
value: "1_month",
|
||||
label: availableDurations["1_month"].label,
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setSubscriptionDuration(
|
||||
value
|
||||
? availableDurations[
|
||||
value.value as keyof typeof availableDurations
|
||||
].number
|
||||
: 1,
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!email ||
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
password !== confirmPassword ||
|
||||
!companyName ||
|
||||
companyUsers <= 0
|
||||
}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Subscription Duration *
|
||||
</label>
|
||||
<Select
|
||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||
options={Object.keys(availableDurations).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
availableDurations[value as keyof typeof availableDurations]
|
||||
.label,
|
||||
}))}
|
||||
defaultValue={{
|
||||
value: "1_month",
|
||||
label: availableDurations["1_month"].label,
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setSubscriptionDuration(
|
||||
value
|
||||
? availableDurations[
|
||||
value.value as keyof typeof availableDurations
|
||||
].number
|
||||
: 1
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!email ||
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
password !== confirmPassword ||
|
||||
!companyName ||
|
||||
companyUsers <= 0
|
||||
}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", "teacher", "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();
|
||||
}
|
||||
}
|
||||
74
src/pages/api/approval-workflows/[id]/index.ts
Normal file
74
src/pages/api/approval-workflows/[id]/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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 { getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { doesEntityAllow } from "@/utils/permissions";
|
||||
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", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
|
||||
return res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
const workflow = await getApprovalWorkflow("active-workflows", id);
|
||||
|
||||
if (!workflow) return res.status(404).json({ ok: false });
|
||||
|
||||
const entity = await getEntityWithRoles(workflow.entityId);
|
||||
if (!entity) return res.status(404).json({ ok: false });
|
||||
|
||||
if (!doesEntityAllow(user, entity, "delete_workflow") && !["admin", "developer"].includes(user.type)) {
|
||||
return res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
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", "teacher", "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", "teacher", "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", "teacher", "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();
|
||||
}
|
||||
32
src/pages/api/approval-workflows/index.ts
Normal file
32
src/pages/api/approval-workflows/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await get(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", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
|
||||
return res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
const entityIdsString = req.query.entityIds as string;
|
||||
|
||||
const entityIdsArray = entityIdsString.split(",");
|
||||
|
||||
if (!["admin", "developer"].includes(user.type)) {
|
||||
// filtering workflows that have user as assignee in at least one of the steps
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray, undefined, user.id));
|
||||
} else {
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Code, Group, Type } from "@/interfaces/user";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { Code, } from "@/interfaces/user";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { doesEntityAllow } from "@/utils/permissions";
|
||||
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { findBy } from "@/utils";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
@@ -30,7 +22,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const { entities } = req.query as { entities?: string[] };
|
||||
if (entities)
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: entities } }).toArray());
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: Array.isArray(entities) ? entities : [entities] } }).toArray());
|
||||
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({}).toArray());
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
const { entity } = req.query as { entity?: string };
|
||||
|
||||
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
|
||||
|
||||
res.status(200).json(snapshot);
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,89 +1,179 @@
|
||||
// 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 { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { Module } from "@/interfaces";
|
||||
import { getUserCorporate } from "@/utils/groups.be";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { Exam, ExamBase, InstructorGender, LevelExam, ListeningExam, ReadingExam, SpeakingExam, Variant } from "@/interfaces/exam";
|
||||
import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
|
||||
import client from "@/lib/mongodb";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { mapBy } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/approval.workflows.be";
|
||||
import { generateExamDifferences } from "@/utils/exam.differences";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
import { access } from "fs";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
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);
|
||||
// Temporary: Adding UUID here but later move to backend.
|
||||
function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
|
||||
const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
|
||||
|
||||
res.status(404).json({ ok: false });
|
||||
exam.parts = exam.parts.map((part) => {
|
||||
const updatedExercises = part.exercises.map((exercise: any) => {
|
||||
arraysToUpdate.forEach((arrayName) => {
|
||||
if (exercise[arrayName] && Array.isArray(exercise[arrayName])) {
|
||||
exercise[arrayName] = exercise[arrayName].map((item: any) => (item.uuid ? item : { ...item, uuid: uuidv4() }));
|
||||
}
|
||||
});
|
||||
return exercise;
|
||||
});
|
||||
return { ...part, exercises: updatedExercises };
|
||||
});
|
||||
return exam;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
res.status(404).json({ ok: false });
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, avoidRepeated, variant, instructorGender } = req.query as {
|
||||
module: Module;
|
||||
avoidRepeated: string;
|
||||
variant?: Variant;
|
||||
instructorGender?: InstructorGender;
|
||||
};
|
||||
const { module, avoidRepeated, variant, instructorGender } = req.query as {
|
||||
module: Module;
|
||||
avoidRepeated: string;
|
||||
variant?: Variant;
|
||||
instructorGender?: InstructorGender;
|
||||
};
|
||||
|
||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
|
||||
res.status(200).json(exams);
|
||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
|
||||
res.status(200).json(exams);
|
||||
}
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requestUser(req, res)
|
||||
if (!user) return res.status(401).json({ ok: false });
|
||||
const user = await requestUser(req, res);
|
||||
if (!user) return res.status(401).json({ ok: false });
|
||||
|
||||
const { module } = req.query as { module: string };
|
||||
const { module } = req.query as { module: string };
|
||||
|
||||
const session = client.startSession();
|
||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
|
||||
const session = client.startSession();
|
||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
|
||||
|
||||
try {
|
||||
const exam = {
|
||||
...req.body,
|
||||
module: module,
|
||||
entities,
|
||||
createdBy: user.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
let exam = {
|
||||
access: "public", // default access is public
|
||||
...req.body,
|
||||
module: module,
|
||||
entities,
|
||||
createdBy: user.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await session.withTransaction(async () => {
|
||||
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
||||
// Temporary: Adding UUID here but later move to backend.
|
||||
exam = addUUIDs(exam);
|
||||
|
||||
// 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 || []);
|
||||
let responseStatus: number;
|
||||
let responseMessage: string;
|
||||
|
||||
if (docSnap !== null && docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) {
|
||||
throw new Error("Name already exists");
|
||||
}
|
||||
await session.withTransaction(async () => {
|
||||
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
||||
|
||||
await db.collection(module).updateOne(
|
||||
{ id: req.body.id },
|
||||
{ $set: { id: req.body.id, ...exam } },
|
||||
{
|
||||
upsert: true,
|
||||
session
|
||||
}
|
||||
);
|
||||
});
|
||||
// Check whether the id of the exam matches another exam with different
|
||||
// owners, throw exception if there is, else allow editing
|
||||
const existingExamOwners = docSnap?.owners ?? [];
|
||||
const newExamOwners = exam.owners ?? [];
|
||||
|
||||
res.status(200).json(exam);
|
||||
const ownersSet = new Set(existingExamOwners);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Transaction failed: ", error);
|
||||
res.status(500).json({ ok: false, error: (error as any).message });
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
|
||||
throw new Error("Name already exists");
|
||||
}
|
||||
|
||||
if (exam.requiresApproval === true) {
|
||||
exam.access = "confidential";
|
||||
}
|
||||
|
||||
await db.collection(module).updateOne(
|
||||
{ id: req.body.id },
|
||||
{ $set: { id: req.body.id, ...exam } },
|
||||
{
|
||||
upsert: true,
|
||||
session,
|
||||
}
|
||||
);
|
||||
|
||||
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response.
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
|
||||
|
||||
// create workflow only if exam is being created for the first time
|
||||
if (docSnap === null) {
|
||||
try {
|
||||
if (exam.requiresApproval === false) {
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`;
|
||||
} else if (isAdmin(user)) {
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`;
|
||||
} else {
|
||||
const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
|
||||
|
||||
if (successCount === totalCount) {
|
||||
responseStatus = 200;
|
||||
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
|
||||
} else if (successCount > 0) {
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`;
|
||||
} else {
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Workflow creation error:", error);
|
||||
responseStatus = 207;
|
||||
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
|
||||
}
|
||||
} else {
|
||||
// if exam was updated, log the updates
|
||||
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
|
||||
|
||||
if (approvalWorkflows) {
|
||||
const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
|
||||
if (differences) {
|
||||
approvalWorkflows.forEach((workflow) => {
|
||||
const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected);
|
||||
|
||||
if (workflow.steps[currentStepIndex].examChanges === undefined) {
|
||||
workflow.steps[currentStepIndex].examChanges = [...differences];
|
||||
} else {
|
||||
workflow.steps[currentStepIndex].examChanges!.push(...differences);
|
||||
}
|
||||
});
|
||||
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(responseStatus).json({
|
||||
message: responseMessage,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Transaction failed: ", error);
|
||||
res.status(500).json({ ok: false, error: (error as any).message });
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {flatten} from "lodash";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { flatten } from "lodash";
|
||||
import { AccessType, Exam } from "@/interfaces/exam";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { requestUser } from "../../../utils/api";
|
||||
import { mapBy } from "../../../utils";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
@@ -14,17 +16,37 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await GET(req, res);
|
||||
|
||||
res.status(404).json({ok: false});
|
||||
res.status(404).json({ ok: false });
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
const user = await requestUser(req, res)
|
||||
if (!user)
|
||||
return res.status(401).json({ ok: false, reason: "You must be logged in!" })
|
||||
const isAdmin = ["admin", "developer"].includes(user.type)
|
||||
const { entities = [] } = req.query as { access?: AccessType, entities?: string[] | string };
|
||||
let entitiesToFetch = Array.isArray(entities) ? entities : entities ? [entities] : []
|
||||
|
||||
if (!isAdmin) {
|
||||
const userEntitiesIDs = mapBy(user.entities || [], 'id')
|
||||
entitiesToFetch = entities ? entitiesToFetch.filter((entity): entity is string => entity ? userEntitiesIDs.includes(entity) : false) : userEntitiesIDs
|
||||
if ((entitiesToFetch.length ?? 0) === 0) {
|
||||
res.status(200).json([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
|
||||
const snapshot = await db.collection(module).find<Exam>({ isDiagnostic: false }).toArray();
|
||||
const snapshot = await db.collection(module).find<Exam>({
|
||||
isDiagnostic: false, ...(isAdmin && (entitiesToFetch.length ?? 0) === 0 ? {
|
||||
} : {
|
||||
entity: { $in: entitiesToFetch }
|
||||
})
|
||||
}).toArray();
|
||||
|
||||
return snapshot.map((doc) => ({
|
||||
...doc,
|
||||
|
||||
@@ -48,4 +48,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
await db.collection("sessions").updateOne({ id: session.id }, { $set: session }, { upsert: true });
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
const sessions = await db.collection("sessions").find<Session>({ user: session.user }, { projection: { id: 1 } }).sort({ date: 1 }).toArray();
|
||||
// Delete old sessions
|
||||
if (sessions.length > 5) {
|
||||
await db.collection("sessions").deleteOne({ id: { $in: sessions.slice(0, sessions.length - 5).map(x => x.id) } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,24 +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").aggregate([
|
||||
{ $match: { user: user } },
|
||||
{ $sort: { "date": 1 } }
|
||||
]).toArray();
|
||||
const { user, query } = req.query as { user: string, query?: string };
|
||||
|
||||
const snapshot = await getDetailedStatsByUser(user, query);
|
||||
res.status(200).json(snapshot);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id }).toArray();
|
||||
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id, status: { $ne: "completed" } }).toArray();
|
||||
|
||||
res.status(200).json(docs);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
});
|
||||
console.log('response', response.data);
|
||||
res.status(response.status).json(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||
}
|
||||
}
|
||||
|
||||
193
src/pages/approval-workflows/[id]/edit.tsx
Normal file
193
src/pages/approval-workflows/[id]/edit.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
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 { findBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
|
||||
import { getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { doesEntityAllow } from "@/utils/permissions";
|
||||
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");
|
||||
|
||||
const entityWithRole = await getEntityWithRoles(workflow.entityId);
|
||||
if (!entityWithRole) return redirect("/approval-workflows");
|
||||
|
||||
if (!doesEntityAllow(user, entityWithRole, "edit_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 = {
|
||||
...workflow,
|
||||
id: workflow._id?.toString() ?? "",
|
||||
requester: user.id, // should it change to the editor?
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
615
src/pages/approval-workflows/[id]/index.tsx
Normal file
615
src/pages/approval-workflows/[id]/index.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
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 { getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { doesEntityAllow } from "@/utils/permissions";
|
||||
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 { MdKeyboardArrowDown, MdKeyboardArrowUp, 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 entityWithRole = await getEntityWithRoles(workflow.entityId);
|
||||
if (!entityWithRole) return redirect("/approval-workflows");
|
||||
|
||||
if (!doesEntityAllow(user, entityWithRole, "view_workflows")) return redirect("/approval-workflows");
|
||||
|
||||
const allAssigneeIds: string[] = [
|
||||
...new Set(
|
||||
workflow.steps
|
||||
.map((step) => {
|
||||
const assignees = step.assignees;
|
||||
if (step.completedBy) {
|
||||
assignees.push(step.completedBy);
|
||||
}
|
||||
return 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 [isAccordionOpen, setIsAccordionOpen] = useState(false);
|
||||
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 and complete the approval process?`)) 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}`, { approved: true })
|
||||
.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("Something went wrong while fetching exam!");
|
||||
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 overflow-y-auto 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" />
|
||||
|
||||
{/* Accordion for Exam Changes */}
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer p-2 rounded-lg"
|
||||
onClick={() => setIsAccordionOpen((prev) => !prev)}
|
||||
>
|
||||
<h2 className="font-medium text-gray-500">
|
||||
Changes ({currentWorkflow.steps[selectedStepIndex].examChanges?.length || "0"})
|
||||
</h2>
|
||||
{isAccordionOpen ? (
|
||||
<MdKeyboardArrowUp size={24} />
|
||||
) : (
|
||||
<MdKeyboardArrowDown size={24} />
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isAccordionOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden mt-2"
|
||||
>
|
||||
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-[300px]">
|
||||
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
|
||||
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
|
||||
<>
|
||||
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
||||
<span className="text-mti-purple-light text-lg">{change.charAt(0)}</span>
|
||||
{change.slice(1)}
|
||||
</p>
|
||||
<hr className="my-3 h-[3px] bg-mti-purple-light rounded-full w-full" />
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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-[200px] 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
415
src/pages/approval-workflows/create.tsx
Normal file
415
src/pages/approval-workflows/create.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import Tip from "@/components/ApprovalWorkflows/Tip";
|
||||
import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
|
||||
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 { mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { getApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { findAllowedEntities } from "@/utils/permissions";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
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", "teacher", "corporate", "mastercorporate"].includes(user.type))
|
||||
return redirect("/")
|
||||
|
||||
const entityIDS = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const userEntitiesWithLabel = findAllowedEntities(user, entities, "configure_workflows");
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
455
src/pages/approval-workflows/index.tsx
Normal file
455
src/pages/approval-workflows/index.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import Tip from "@/components/ApprovalWorkflows/Tip";
|
||||
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 { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { getSpecificUsers } from "@/utils/users.be";
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable, getPaginationRowModel } 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 { useRouter } from "next/router";
|
||||
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 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",
|
||||
},
|
||||
];
|
||||
|
||||
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 entityIDS = mapBy(user.entities, "id");
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
|
||||
|
||||
const workflows = await getApprovalWorkflows("active-workflows", allowedEntities.map(entity => entity.id));
|
||||
|
||||
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: allowedEntities,
|
||||
}),
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
initialWorkflows: ApprovalWorkflow[];
|
||||
workflowsAssignees: User[];
|
||||
userEntitiesWithLabel: EntityWithRoles[];
|
||||
}
|
||||
|
||||
export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) {
|
||||
const entitiesString = userEntitiesWithLabel.map(entity => entity.id).join(",");
|
||||
const { workflows, reload } = useApprovalWorkflows(entitiesString);
|
||||
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 router = useRouter();
|
||||
|
||||
/* const allowedEntities = useAllowedEntities(user, userEntitiesWithLabel, "view_workflows");
|
||||
const allowedSomeEntities = useAllowedEntitiesSomePermissions(user, userEntitiesWithLabel, ["view_workflows", "create_workflow"]); */
|
||||
|
||||
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 === 403) {
|
||||
toast.error("You do not have permission to delete this 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"> */
|
||||
className={clsx("inline-block rounded-full px-3 py-1 text-sm font-medium text-white",
|
||||
module === "speaking" ? "bg-ielts-speaking" :
|
||||
module === "reading" ? "bg-ielts-reading" :
|
||||
module === "writing" ? "bg-ielts-writing" :
|
||||
module === "listening" ? "bg-ielts-listening" :
|
||||
module === "level" ? "bg-ielts-level" :
|
||||
"bg-slate-700"
|
||||
)}>
|
||||
{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"
|
||||
disabled={!doesEntityAllow(user, userEntitiesWithLabel.find(entity => entity.id === row.original.entityId)!, "delete_workflow")}
|
||||
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 && (
|
||||
<button
|
||||
data-tip="Edit"
|
||||
className="cursor-pointer tooltip"
|
||||
disabled={!doesEntityAllow(user, userEntitiesWithLabel.find(entity => entity.id === row.original.entityId)!, "edit_workflow")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/approval-workflows/${row.original._id?.toString()}/edit`);
|
||||
}}>
|
||||
<FaRegEdit className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredWorkflows,
|
||||
columns: columns,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
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 className="mt-2 flex flex-row gap-2 w-full justify-end items-center">
|
||||
<button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{"<<"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{"<"}
|
||||
</button>
|
||||
<span className="px-4 text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{">"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
|
||||
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{">>"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user