Compare commits
120 Commits
ENCOA-316-
...
fixStudent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f2a21a76 | ||
|
|
c49b1c8070 | ||
|
|
655e019bf6 | ||
|
|
d7a8f496c0 | ||
|
|
5e363e9951 | ||
|
|
3370f3c648 | ||
|
|
d77336374d | ||
|
|
e765dea106 | ||
|
|
75fb9490e0 | ||
|
|
3ef7998193 | ||
|
|
32cd8495d6 | ||
|
|
4e3cfec9e8 | ||
|
|
ba8cc342b1 | ||
|
|
dd8f821e35 | ||
|
|
a4ef2222e2 | ||
|
|
93d9e49358 | ||
|
|
5d0a3acbee | ||
|
|
340ff5a30a | ||
|
|
37908423eb | ||
|
|
b388ee399f | ||
|
|
4ac11df6ae | ||
|
|
14e2702aca | ||
|
|
fec3b51553 | ||
|
|
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 | ||
|
|
a40ae04aa3 | ||
|
|
8db47a3962 | ||
|
|
ab81a1753d | ||
|
|
73610dc273 | ||
|
|
ac072b0a5a | ||
|
|
2c0153e055 | ||
|
|
2eff08bf86 | ||
|
|
f71a7182dd | ||
|
|
1f7639a30e | ||
|
|
41d09eaad8 | ||
|
|
f6b0c96b3b | ||
|
|
dcd25465fd | ||
|
|
c921d54d50 | ||
|
|
a4f60455b5 | ||
|
|
a0936cb1a4 | ||
|
|
aa76c2b54b | ||
|
|
4e81c08adb | ||
|
|
4895f00184 | ||
|
|
8f8d5e5640 | ||
|
|
73e2e95449 | ||
|
|
48187fc7f2 | ||
|
|
01222b3a13 | ||
|
|
39a397d262 | ||
|
|
50d2841349 | ||
|
|
f485c782f3 | ||
|
|
c2c9b3374c | ||
|
|
66d23b4140 | ||
|
|
580e319fb9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,3 +41,5 @@ next-env.d.ts
|
|||||||
.yarn/*
|
.yarn/*
|
||||||
.history*
|
.history*
|
||||||
__ENV.js
|
__ENV.js
|
||||||
|
|
||||||
|
settings.json
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"country-codes-list": "^1.6.11",
|
"country-codes-list": "^1.6.11",
|
||||||
"currency-symbol-map": "^5.1.0",
|
"currency-symbol-map": "^5.1.0",
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
|
"deep-diff": "^1.0.2",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||||
"@types/blob-stream": "^0.1.33",
|
"@types/blob-stream": "^0.1.33",
|
||||||
|
"@types/deep-diff": "^1.0.5",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
"@types/howler": "^2.2.11",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
@@ -112,5 +114,6 @@
|
|||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.2.4"
|
"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 {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import {getExam, getExamById} from "@/utils/exams";
|
import {getExam} from "@/utils/exams";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import {writingMarking} from "@/utils/score";
|
|
||||||
import {Menu} from "@headlessui/react";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import { useState} from "react";
|
||||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
import { BsQuestionSquare} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import validateBlanks from "../validateBlanks";
|
|||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||||
import PromptEdit from "../../Shared/PromptEdit";
|
import PromptEdit from "../../Shared/PromptEdit";
|
||||||
|
import { uuidv4 } from "@firebase/util";
|
||||||
|
|
||||||
interface Word {
|
interface Word {
|
||||||
letter: string;
|
letter: string;
|
||||||
@@ -72,6 +73,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
...local,
|
...local,
|
||||||
text: blanksState.text,
|
text: blanksState.text,
|
||||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -145,6 +147,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
setLocal(prev => ({
|
setLocal(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -189,6 +192,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
...prev,
|
...prev,
|
||||||
words: newWords,
|
words: newWords,
|
||||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -217,6 +221,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
...prev,
|
...prev,
|
||||||
words: newWords,
|
words: newWords,
|
||||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -234,6 +239,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
setLocal(prev => ({
|
setLocal(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { toast } from "react-toastify";
|
|||||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||||
import MCOption from "./MCOption";
|
import MCOption from "./MCOption";
|
||||||
|
import { uuidv4 } from "@firebase/util";
|
||||||
|
|
||||||
|
|
||||||
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
@@ -69,6 +70,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
...local,
|
...local,
|
||||||
text: blanksState.text,
|
text: blanksState.text,
|
||||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -139,6 +141,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
setLocal(prev => ({
|
setLocal(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -168,6 +171,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
...prev,
|
...prev,
|
||||||
words: newWords,
|
words: newWords,
|
||||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -217,6 +221,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
...prev,
|
...prev,
|
||||||
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
|
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
|
||||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
@@ -234,6 +239,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
|
|
||||||
blanksMissingWords.forEach(blank => {
|
blanksMissingWords.forEach(blank => {
|
||||||
const newMCOption: FillBlanksMCOption = {
|
const newMCOption: FillBlanksMCOption = {
|
||||||
|
uuid: uuidv4(),
|
||||||
id: blank.id.toString(),
|
id: blank.id.toString(),
|
||||||
options: {
|
options: {
|
||||||
A: 'Option A',
|
A: 'Option A',
|
||||||
@@ -249,6 +255,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
...prev,
|
...prev,
|
||||||
words: newWords,
|
words: newWords,
|
||||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||||
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||||
id,
|
id,
|
||||||
solution
|
solution
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
|
|||||||
import { DragEndEvent } from '@dnd-kit/core';
|
import { DragEndEvent } from '@dnd-kit/core';
|
||||||
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
|
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
|
||||||
import PromptEdit from '../Shared/PromptEdit';
|
import PromptEdit from '../Shared/PromptEdit';
|
||||||
|
import { uuidv4 } from '@firebase/util';
|
||||||
|
|
||||||
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
@@ -98,6 +99,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
|||||||
sentences: [
|
sentences: [
|
||||||
...local.sentences,
|
...local.sentences,
|
||||||
{
|
{
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId,
|
id: newId,
|
||||||
sentence: "",
|
sentence: "",
|
||||||
solution: ""
|
solution: ""
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { MdAdd } from "react-icons/md";
|
import { MdAdd } from "react-icons/md";
|
||||||
import Alert, { AlertItem } from "../../Shared/Alert";
|
import Alert, { AlertItem } from "../../Shared/Alert";
|
||||||
import PromptEdit from "../../Shared/PromptEdit";
|
import PromptEdit from "../../Shared/PromptEdit";
|
||||||
|
import { uuidv4 } from "@firebase/util";
|
||||||
|
|
||||||
|
|
||||||
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
||||||
@@ -57,6 +58,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
|||||||
{
|
{
|
||||||
prompt: "",
|
prompt: "",
|
||||||
solution: "",
|
solution: "",
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId,
|
id: newId,
|
||||||
options,
|
options,
|
||||||
variant: "text"
|
variant: "text"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import SortableQuestion from '../../Shared/SortableQuestion';
|
|||||||
import setEditingAlert from '../../Shared/setEditingAlert';
|
import setEditingAlert from '../../Shared/setEditingAlert';
|
||||||
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
||||||
import PromptEdit from '../../Shared/PromptEdit';
|
import PromptEdit from '../../Shared/PromptEdit';
|
||||||
|
import { uuidv4 } from '@firebase/util';
|
||||||
|
|
||||||
interface MultipleChoiceProps {
|
interface MultipleChoiceProps {
|
||||||
exercise: MultipleChoiceExercise;
|
exercise: MultipleChoiceExercise;
|
||||||
@@ -120,6 +121,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
|||||||
{
|
{
|
||||||
prompt: "",
|
prompt: "",
|
||||||
solution: "",
|
solution: "",
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId,
|
id: newId,
|
||||||
options,
|
options,
|
||||||
variant: "text"
|
variant: "text"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import setEditingAlert from '../Shared/setEditingAlert';
|
|||||||
import { DragEndEvent } from '@dnd-kit/core';
|
import { DragEndEvent } from '@dnd-kit/core';
|
||||||
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
||||||
import PromptEdit from '../Shared/PromptEdit';
|
import PromptEdit from '../Shared/PromptEdit';
|
||||||
|
import { uuidv4 } from '@firebase/util';
|
||||||
|
|
||||||
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
@@ -50,6 +51,7 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
|||||||
{
|
{
|
||||||
prompt: "",
|
prompt: "",
|
||||||
solution: undefined,
|
solution: undefined,
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId
|
id: newId
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { validateEmptySolutions, validateQuestionText, validateWordCount } from
|
|||||||
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||||
import PromptEdit from '../Shared/PromptEdit';
|
import PromptEdit from '../Shared/PromptEdit';
|
||||||
|
import { uuidv4 } from '@firebase/util';
|
||||||
|
|
||||||
|
|
||||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
|
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 newId = (Math.max(...existingIds, 0) + 1).toString();
|
||||||
|
|
||||||
const newQuestion = {
|
const newQuestion = {
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId,
|
id: newId,
|
||||||
questionText: "New question"
|
questionText: "New question"
|
||||||
};
|
};
|
||||||
@@ -113,6 +115,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
|||||||
const updatedText = reconstructText(updatedQuestions);
|
const updatedText = reconstructText(updatedQuestions);
|
||||||
|
|
||||||
const updatedSolutions = [...local.solutions, {
|
const updatedSolutions = [...local.solutions, {
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId,
|
id: newId,
|
||||||
solution: [""]
|
solution: [""]
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { validateQuestions, validateEmptySolutions, validateWordCount } from "./
|
|||||||
import Header from "../../Shared/Header";
|
import Header from "../../Shared/Header";
|
||||||
import BlanksFormEditor from "./BlanksFormEditor";
|
import BlanksFormEditor from "./BlanksFormEditor";
|
||||||
import PromptEdit from "../Shared/PromptEdit";
|
import PromptEdit from "../Shared/PromptEdit";
|
||||||
|
import { uuidv4 } from "@firebase/util";
|
||||||
|
|
||||||
|
|
||||||
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
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 newLine = `New question with blank {{${newId}}}`;
|
||||||
const updatedQuestions = [...parsedQuestions, {
|
const updatedQuestions = [...parsedQuestions, {
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId,
|
id: newId,
|
||||||
parts: parseLine(newLine),
|
parts: parseLine(newLine),
|
||||||
editingPlaceholders: true
|
editingPlaceholders: true
|
||||||
@@ -121,6 +123,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
|||||||
.join('\\n') + '\\n';
|
.join('\\n') + '\\n';
|
||||||
|
|
||||||
const updatedSolutions = [...local.solutions, {
|
const updatedSolutions = [...local.solutions, {
|
||||||
|
uuid: uuidv4(),
|
||||||
id: newId,
|
id: newId,
|
||||||
solution: [""]
|
solution: [""]
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface SettingsEditorProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
canPreview: boolean;
|
canPreview: boolean;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
submitModule: () => void;
|
submitModule: (requiresApproval: boolean) => void;
|
||||||
preview: () => void;
|
preview: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
|||||||
}, [updateLocalAndScheduleGlobal]);
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}>
|
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit -2xl:w-full`}>
|
||||||
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
|
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -148,18 +148,33 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
{children}
|
{children}
|
||||||
<div className="flex flex-row justify-between mt-4">
|
<div className="flex flex-col gap-3 mt-4">
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
|
"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`,
|
`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"
|
"disabled:cursor-not-allowed disabled:text-gray-200"
|
||||||
)}
|
)}
|
||||||
onClick={submitModule}
|
onClick={() => submitModule(true)}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
>
|
>
|
||||||
<FaFileUpload className="mr-2" size={18} />
|
<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>
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -171,7 +186,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
|||||||
disabled={!canPreview}
|
disabled={!canPreview}
|
||||||
>
|
>
|
||||||
<FaEye className="mr-2" size={18} />
|
<FaEye className="mr-2" size={18} />
|
||||||
Preview Module
|
Preview module
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ListeningComponents from "./listening/components";
|
|||||||
import ReadingComponents from "./reading/components";
|
import ReadingComponents from "./reading/components";
|
||||||
import SpeakingComponents from "./speaking/components";
|
import SpeakingComponents from "./speaking/components";
|
||||||
import SectionPicker from "./Shared/SectionPicker";
|
import SectionPicker from "./Shared/SectionPicker";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
|
||||||
|
|
||||||
const LevelSettings: React.FC = () => {
|
const LevelSettings: React.FC = () => {
|
||||||
@@ -37,7 +38,7 @@ const LevelSettings: React.FC = () => {
|
|||||||
difficulty,
|
difficulty,
|
||||||
sections,
|
sections,
|
||||||
minTimer,
|
minTimer,
|
||||||
isPrivate,
|
access,
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
|
||||||
@@ -75,7 +76,7 @@ const LevelSettings: React.FC = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitLevel = async () => {
|
const submitLevel = async (requiresApproval: boolean) => {
|
||||||
if (title === "") {
|
if (title === "") {
|
||||||
toast.error("Enter a title for the exam!");
|
toast.error("Enter a title for the exam!");
|
||||||
return;
|
return;
|
||||||
@@ -194,17 +195,23 @@ const LevelSettings: React.FC = () => {
|
|||||||
category: s.settings.category
|
category: s.settings.category
|
||||||
};
|
};
|
||||||
}).filter(part => part.exercises.length > 0),
|
}).filter(part => part.exercises.length > 0),
|
||||||
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "level",
|
module: "level",
|
||||||
id: title,
|
id: title,
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await axios.post('/api/exam/level', exam);
|
const result = await axios.post('/api/exam/level', exam);
|
||||||
playSound("sent");
|
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 => {
|
Array.from(audioMap.values()).forEach(url => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
@@ -237,7 +244,7 @@ const LevelSettings: React.FC = () => {
|
|||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
} as LevelExam);
|
} as LevelExam);
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
|
|||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
<div className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4">
|
||||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import Dropdown from "../Shared/SettingsDropdown";
|
|
||||||
import ExercisePicker from "../../ExercisePicker";
|
|
||||||
import SettingsEditor from "..";
|
import SettingsEditor from "..";
|
||||||
import GenerateBtn from "../Shared/GenerateBtn";
|
import { ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { generate } from "../Shared/Generate";
|
|
||||||
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
|
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import useSettingsState from "../../Hooks/useSettingsState";
|
import useSettingsState from "../../Hooks/useSettingsState";
|
||||||
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -26,7 +20,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
difficulty,
|
difficulty,
|
||||||
sections,
|
sections,
|
||||||
minTimer,
|
minTimer,
|
||||||
isPrivate,
|
access,
|
||||||
instructionsState
|
instructionsState
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
@@ -64,7 +58,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const submitListening = async () => {
|
const submitListening = async (requiresApproval: boolean) => {
|
||||||
if (title === "") {
|
if (title === "") {
|
||||||
toast.error("Enter a title for the exam!");
|
toast.error("Enter a title for the exam!");
|
||||||
return;
|
return;
|
||||||
@@ -137,19 +131,25 @@ const ListeningSettings: React.FC = () => {
|
|||||||
category: s.settings.category
|
category: s.settings.category
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "listening",
|
module: "listening",
|
||||||
id: title,
|
id: title,
|
||||||
variant: sections.length === 4 ? "full" : "partial",
|
variant: sections.length === 4 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
instructions: instructionsURL
|
instructions: instructionsURL
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await axios.post('/api/exam/listening', exam);
|
const result = await axios.post('/api/exam/listening', exam);
|
||||||
playSound("sent");
|
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 {
|
} else {
|
||||||
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
||||||
@@ -185,7 +185,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: sections.length === 4 ? "full" : "partial",
|
variant: sections.length === 4 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
instructions: instructionsState.currentInstructionsURL
|
instructions: instructionsState.currentInstructionsURL
|
||||||
} as ListeningExam);
|
} as ListeningExam);
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
|
|||||||
@@ -5,24 +5,36 @@ import ExercisePicker from "../../ExercisePicker";
|
|||||||
import { generate } from "../Shared/Generate";
|
import { generate } from "../Shared/Generate";
|
||||||
import GenerateBtn from "../Shared/GenerateBtn";
|
import GenerateBtn from "../Shared/GenerateBtn";
|
||||||
import { LevelPart, ReadingPart } from "@/interfaces/exam";
|
import { LevelPart, ReadingPart } from "@/interfaces/exam";
|
||||||
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types";
|
import {
|
||||||
|
LevelSectionSettings,
|
||||||
|
ReadingSectionSettings,
|
||||||
|
} from "@/stores/examEditor/types";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
localSettings: ReadingSectionSettings | LevelSectionSettings;
|
localSettings: ReadingSectionSettings | LevelSectionSettings;
|
||||||
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
|
updateLocalAndScheduleGlobal: (
|
||||||
|
updates: Partial<ReadingSectionSettings | LevelSectionSettings>,
|
||||||
|
schedule?: boolean
|
||||||
|
) => void;
|
||||||
currentSection: ReadingPart | LevelPart;
|
currentSection: ReadingPart | LevelPart;
|
||||||
generatePassageDisabled?: boolean;
|
generatePassageDisabled?: boolean;
|
||||||
levelId?: number;
|
levelId?: number;
|
||||||
level?: boolean;
|
level?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => {
|
const ReadingComponents: React.FC<Props> = ({
|
||||||
|
localSettings,
|
||||||
|
updateLocalAndScheduleGlobal,
|
||||||
|
currentSection,
|
||||||
|
levelId,
|
||||||
|
level = false,
|
||||||
|
generatePassageDisabled = false,
|
||||||
|
}) => {
|
||||||
const { currentModule } = useExamEditorStore();
|
const { currentModule } = useExamEditorStore();
|
||||||
const {
|
const { focusedSection, difficulty } = useExamEditorStore(
|
||||||
focusedSection,
|
(state) => state.modules[currentModule]
|
||||||
difficulty,
|
);
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
|
||||||
|
|
||||||
const generatePassage = useCallback(() => {
|
const generatePassage = useCallback(() => {
|
||||||
generate(
|
generate(
|
||||||
@@ -30,25 +42,32 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
|
|||||||
"reading",
|
"reading",
|
||||||
"passage",
|
"passage",
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
queryParams: {
|
queryParams: {
|
||||||
difficulty,
|
difficulty,
|
||||||
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
|
...(localSettings.readingTopic && {
|
||||||
}
|
topic: localSettings.readingTopic,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
(data: any) => [{
|
},
|
||||||
|
(data: any) => [
|
||||||
|
{
|
||||||
title: data.title,
|
title: data.title,
|
||||||
text: data.text
|
text: data.text,
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
level ? focusedSection : undefined,
|
level ? focusedSection : undefined,
|
||||||
level
|
level
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
|
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
|
||||||
|
|
||||||
const onTopicChange = useCallback((readingTopic: string) => {
|
const onTopicChange = useCallback(
|
||||||
|
(readingTopic: string) => {
|
||||||
updateLocalAndScheduleGlobal({ readingTopic });
|
updateLocalAndScheduleGlobal({ readingTopic });
|
||||||
}, [updateLocalAndScheduleGlobal]);
|
},
|
||||||
|
[updateLocalAndScheduleGlobal]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,13 +75,19 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
|
|||||||
title="Generate Passage"
|
title="Generate Passage"
|
||||||
module="reading"
|
module="reading"
|
||||||
open={localSettings.isPassageOpen}
|
open={localSettings.isPassageOpen}
|
||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
|
setIsOpen={(isOpen: boolean) =>
|
||||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)
|
||||||
|
}
|
||||||
|
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
|
||||||
disabled={generatePassageDisabled}
|
disabled={generatePassageDisabled}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
<div
|
||||||
|
className="flex flex-row flex-wrap gap-2 items-center justify-center px-2 pb-4 "
|
||||||
|
>
|
||||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Topic (Optional)
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
key={`section-${focusedSection}`}
|
key={`section-${focusedSection}`}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -88,14 +113,26 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
|
|||||||
title="Add Exercises"
|
title="Add Exercises"
|
||||||
module="reading"
|
module="reading"
|
||||||
open={localSettings.isReadingTopicOpean}
|
open={localSettings.isReadingTopicOpean}
|
||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
|
setIsOpen={(isOpen: boolean) =>
|
||||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })
|
||||||
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
|
}
|
||||||
|
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
|
||||||
|
disabled={
|
||||||
|
currentSection === undefined ||
|
||||||
|
currentSection.text === undefined ||
|
||||||
|
currentSection.text.content === "" ||
|
||||||
|
currentSection.text.title === ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ExercisePicker
|
<ExercisePicker
|
||||||
module="reading"
|
module="reading"
|
||||||
sectionId={levelId !== undefined ? levelId : focusedSection}
|
sectionId={levelId !== undefined ? levelId : focusedSection}
|
||||||
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
|
extraArgs={{
|
||||||
|
text:
|
||||||
|
currentSection === undefined || currentSection.text === undefined
|
||||||
|
? ""
|
||||||
|
: currentSection.text.content,
|
||||||
|
}}
|
||||||
levelSectionId={focusedSection}
|
levelSectionId={focusedSection}
|
||||||
level={level}
|
level={level}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,43 +25,41 @@ const ReadingSettings: React.FC = () => {
|
|||||||
} = usePersistentExamStore();
|
} = usePersistentExamStore();
|
||||||
|
|
||||||
const { currentModule, title } = useExamEditorStore();
|
const { currentModule, title } = useExamEditorStore();
|
||||||
const {
|
const { focusedSection, difficulty, sections, minTimer, access, type } =
|
||||||
focusedSection,
|
useExamEditorStore((state) => state.modules[currentModule]);
|
||||||
difficulty,
|
|
||||||
sections,
|
|
||||||
minTimer,
|
|
||||||
isPrivate,
|
|
||||||
type,
|
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
|
||||||
|
|
||||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>(
|
const { localSettings, updateLocalAndScheduleGlobal } =
|
||||||
currentModule,
|
useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
|
||||||
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[] = [
|
const defaultPresets: Option[] = [
|
||||||
{
|
{
|
||||||
label: "Preset: Reading Passage 1",
|
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."
|
value:
|
||||||
|
"Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Preset: Reading Passage 2",
|
label: "Preset: Reading Passage 2",
|
||||||
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
|
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",
|
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."
|
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(
|
const canPreviewOrSubmit = sections.some(
|
||||||
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
(s) =>
|
||||||
|
(s.state as ReadingPart).exercises &&
|
||||||
|
(s.state as ReadingPart).exercises.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitReading = () => {
|
const submitReading = (requiresApproval: boolean) => {
|
||||||
if (title === "") {
|
if (title === "") {
|
||||||
toast.error("Enter a title for the exam!");
|
toast.error("Enter a title for the exam!");
|
||||||
return;
|
return;
|
||||||
@@ -72,29 +70,39 @@ const ReadingSettings: React.FC = () => {
|
|||||||
return {
|
return {
|
||||||
...exercise,
|
...exercise,
|
||||||
intro: localSettings.currentIntro,
|
intro: localSettings.currentIntro,
|
||||||
category: localSettings.category
|
category: localSettings.category,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "reading",
|
module: "reading",
|
||||||
id: title,
|
id: title,
|
||||||
variant: sections.length === 3 ? "full" : "partial",
|
variant: sections.length === 3 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
type: type!
|
type: type!,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios.post(`/api/exam/reading`, exam)
|
axios
|
||||||
|
.post(`/api/exam/reading`, exam)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
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) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
toast.error(
|
||||||
})
|
error.response.data.error ||
|
||||||
}
|
"Something went wrong while submitting, please try again later."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const preview = () => {
|
const preview = () => {
|
||||||
setExam({
|
setExam({
|
||||||
@@ -103,7 +111,7 @@ const ReadingSettings: React.FC = () => {
|
|||||||
return {
|
return {
|
||||||
...exercises,
|
...exercises,
|
||||||
intro: s.settings.currentIntro,
|
intro: s.settings.currentIntro,
|
||||||
category: s.settings.category
|
category: s.settings.category,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
minTimer,
|
minTimer,
|
||||||
@@ -112,15 +120,15 @@ const ReadingSettings: React.FC = () => {
|
|||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access: access,
|
||||||
type: type!
|
type: type!,
|
||||||
} as ReadingExam);
|
} as ReadingExam);
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
setPartIndex(0);
|
setPartIndex(0);
|
||||||
setBgColor("bg-white");
|
setBgColor("bg-white");
|
||||||
openDetachedTab("popout?type=Exam&module=reading", router)
|
openDetachedTab("popout?type=Exam&module=reading", router);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsEditor
|
<SettingsEditor
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import openDetachedTab from "@/utils/popout";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import SpeakingComponents from "./components";
|
import SpeakingComponents from "./components";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
|
||||||
export interface Avatar {
|
export interface Avatar {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,7 +30,7 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
} = usePersistentExamStore();
|
} = usePersistentExamStore();
|
||||||
|
|
||||||
const { title, currentModule } = useExamEditorStore();
|
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;
|
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 === "") {
|
if (title === "") {
|
||||||
toast.error("Enter a title for the exam!");
|
toast.error("Enter a title for the exam!");
|
||||||
return;
|
return;
|
||||||
@@ -180,16 +181,22 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
minTimer,
|
minTimer,
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
id: title,
|
id: title,
|
||||||
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
instructorGender: "varied",
|
instructorGender: "varied",
|
||||||
private: isPrivate,
|
access,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await axios.post('/api/exam/speaking', exam);
|
const result = await axios.post('/api/exam/speaking', exam);
|
||||||
playSound("sent");
|
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 => {
|
Array.from(urlMap.values()).forEach(url => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
@@ -232,7 +239,7 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
} as SpeakingExam);
|
} as SpeakingExam);
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import axios from "axios";
|
|||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import WritingComponents from "./components";
|
import WritingComponents from "./components";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
|
||||||
const WritingSettings: React.FC = () => {
|
const WritingSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +23,7 @@ const WritingSettings: React.FC = () => {
|
|||||||
const {
|
const {
|
||||||
minTimer,
|
minTimer,
|
||||||
difficulty,
|
difficulty,
|
||||||
isPrivate,
|
access,
|
||||||
sections,
|
sections,
|
||||||
focusedSection,
|
focusedSection,
|
||||||
type,
|
type,
|
||||||
@@ -79,14 +81,14 @@ const WritingSettings: React.FC = () => {
|
|||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
type: type!
|
type: type!
|
||||||
});
|
});
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
openDetachedTab("popout?type=Exam&module=writing", router)
|
openDetachedTab("popout?type=Exam&module=writing", router)
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitWriting = async () => {
|
const submitWriting = async (requiresApproval: boolean) => {
|
||||||
if (title === "") {
|
if (title === "") {
|
||||||
toast.error("Enter a title for the exam!");
|
toast.error("Enter a title for the exam!");
|
||||||
return;
|
return;
|
||||||
@@ -129,16 +131,22 @@ const WritingSettings: React.FC = () => {
|
|||||||
minTimer,
|
minTimer,
|
||||||
module: "writing",
|
module: "writing",
|
||||||
id: title,
|
id: title,
|
||||||
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
access,
|
||||||
type: type!
|
type: type!
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await axios.post(`/api/exam/writing`, exam)
|
const result = await axios.post(`/api/exam/writing`, exam)
|
||||||
playSound("sent");
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Error submitting exam:', error);
|
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}) => {
|
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-between items-center mr-4">
|
<div className="flex w-full justify-between items-center mr-4">
|
||||||
<span className="font-semibold">{label(type, firstId, lastId)}</span>
|
<span className="font-semibold ellipsis-2">{label(type, firstId, lastId)}</span>
|
||||||
<div className="text-sm font-light italic">{previewLabel(prompt)}</div>
|
<div className="text-sm font-light italic ellipsis-2">{previewLabel(prompt)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SectionRenderer from "./SectionRenderer";
|
import SectionRenderer from "./SectionRenderer";
|
||||||
import Checkbox from "../Low/Checkbox";
|
|
||||||
import Input from "../Low/Input";
|
import Input from "../Low/Input";
|
||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import { Difficulty } from "@/interfaces/exam";
|
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
||||||
@@ -20,23 +19,52 @@ import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import ResetModule from "./Standalone/ResetModule";
|
import ResetModule from "./Standalone/ResetModule";
|
||||||
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import 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 ModuleSettings: Record<Module, React.ComponentType> = {
|
||||||
|
reading: ReadingSettings,
|
||||||
|
writing: WritingSettings,
|
||||||
|
speaking: SpeakingSettings,
|
||||||
|
listening: ListeningSettings,
|
||||||
|
level: LevelSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExamEditor: React.FC<{
|
||||||
|
levelParts?: number;
|
||||||
|
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||||
|
entitiesAllowConfExams: EntityWithRoles[];
|
||||||
|
entitiesAllowPublicExams: EntityWithRoles[];
|
||||||
|
}> = ({
|
||||||
|
levelParts = 0,
|
||||||
|
entitiesAllowEditPrivacy = [],
|
||||||
|
entitiesAllowConfExams = [],
|
||||||
|
entitiesAllowPublicExams = [],
|
||||||
|
}) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
const {
|
const {
|
||||||
sections,
|
sections,
|
||||||
minTimer,
|
minTimer,
|
||||||
expandedSections,
|
expandedSections,
|
||||||
examLabel,
|
examLabel,
|
||||||
isPrivate,
|
access,
|
||||||
difficulty,
|
difficulty,
|
||||||
sectionLabels,
|
sectionLabels,
|
||||||
importModule
|
importModule,
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
} = useExamEditorStore((state) => state.modules[currentModule]);
|
||||||
|
|
||||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
|
const [numberOfLevelParts, setNumberOfLevelParts] = useState(
|
||||||
|
levelParts !== 0 ? levelParts : 1
|
||||||
|
);
|
||||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||||
|
|
||||||
// For exam edits
|
// For exam edits
|
||||||
@@ -44,34 +72,39 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
|
|||||||
if (levelParts !== 0) {
|
if (levelParts !== 0) {
|
||||||
setNumberOfLevelParts(levelParts);
|
setNumberOfLevelParts(levelParts);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_MODULE',
|
type: "UPDATE_MODULE",
|
||||||
payload: {
|
payload: {
|
||||||
updates: {
|
updates: {
|
||||||
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
label: `Part ${i + 1}`
|
label: `Part ${i + 1}`,
|
||||||
}))
|
})),
|
||||||
},
|
},
|
||||||
module: "level"
|
module: "level",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [levelParts])
|
}, [levelParts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentSections = sections;
|
const currentSections = sections;
|
||||||
const currentLabels = sectionLabels;
|
const currentLabels = sectionLabels;
|
||||||
let updatedSections: SectionState[];
|
let updatedSections: SectionState[];
|
||||||
let updatedLabels: any;
|
let updatedLabels: any;
|
||||||
if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) {
|
if (
|
||||||
|
(currentModule === "level" &&
|
||||||
|
currentSections.length !== currentLabels.length) ||
|
||||||
|
numberOfLevelParts !== currentSections.length
|
||||||
|
) {
|
||||||
const newSections = [...currentSections];
|
const newSections = [...currentSections];
|
||||||
const newLabels = [...currentLabels];
|
const newLabels = [...currentLabels];
|
||||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||||
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1));
|
if (currentSections.length !== numberOfLevelParts)
|
||||||
|
newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||||
newLabels.push({
|
newLabels.push({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
label: `Part ${i + 1}`
|
label: `Part ${i + 1}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updatedSections = newSections;
|
updatedSections = newSections;
|
||||||
@@ -83,99 +116,150 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedExpandedSections = expandedSections.filter(
|
const updatedExpandedSections = expandedSections.filter((sectionId) =>
|
||||||
sectionId => updatedSections.some(section => section.sectionId === sectionId)
|
updatedSections.some((section) => section.sectionId === sectionId)
|
||||||
);
|
);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_MODULE',
|
type: "UPDATE_MODULE",
|
||||||
payload: {
|
payload: {
|
||||||
updates: {
|
updates: {
|
||||||
sections: updatedSections,
|
sections: updatedSections,
|
||||||
sectionLabels: updatedLabels,
|
sectionLabels: updatedLabels,
|
||||||
expandedSections: updatedExpandedSections
|
expandedSections: updatedExpandedSections,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [numberOfLevelParts]);
|
}, [numberOfLevelParts]);
|
||||||
|
|
||||||
const sectionIds = sections.map((section) => section.sectionId)
|
const sectionIds = useMemo(
|
||||||
|
() => sections.map((section) => section.sectionId),
|
||||||
|
[sections]
|
||||||
|
);
|
||||||
|
|
||||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
const updateModule = useCallback(
|
||||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
(updates: Partial<ModuleState>) => {
|
||||||
}, [dispatch]);
|
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSection = (sectionId: number) => {
|
const toggleSection = useCallback(
|
||||||
|
(sectionId: number) => {
|
||||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||||
toast.error("Include at least one section!");
|
toast.error("Include at least one section!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: 'TOGGLE_SECTION', payload: { sectionId } });
|
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
||||||
};
|
},
|
||||||
|
[dispatch, expandedSections, sectionIds]
|
||||||
|
);
|
||||||
|
|
||||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
const Settings = useMemo(
|
||||||
reading: ReadingSettings,
|
() => ModuleSettings[currentModule],
|
||||||
writing: WritingSettings,
|
[currentModule]
|
||||||
speaking: SpeakingSettings,
|
);
|
||||||
listening: ListeningSettings,
|
|
||||||
level: LevelSettings
|
|
||||||
};
|
|
||||||
|
|
||||||
const Settings = ModuleSettings[currentModule];
|
const showImport = useMemo(
|
||||||
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
|
() =>
|
||||||
|
importModule && ["reading", "listening", "level"].includes(currentModule),
|
||||||
|
[importModule, currentModule]
|
||||||
|
);
|
||||||
|
|
||||||
const updateLevelParts = (parts: number) => {
|
const accessTypeOptions = useMemo(() => {
|
||||||
setNumberOfLevelParts(parts);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> : (
|
{showImport ? (
|
||||||
|
<ImportOrStartFromScratch
|
||||||
|
module={currentModule}
|
||||||
|
setNumberOfLevelParts={updateLevelParts}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
{isResetModuleOpen && <ResetModule module={currentModule} isOpen={isResetModuleOpen} setIsOpen={setIsResetModuleOpen} setNumberOfLevelParts={setNumberOfLevelParts}/>}
|
{isResetModuleOpen && (
|
||||||
<div className="flex gap-4 w-full items-center">
|
<ResetModule
|
||||||
|
module={currentModule}
|
||||||
|
isOpen={isResetModuleOpen}
|
||||||
|
setIsOpen={setIsResetModuleOpen}
|
||||||
|
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={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 ">
|
<div className="flex flex-col gap-3 ">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Timer
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
name="minTimer"
|
name="minTimer"
|
||||||
onChange={(e) => updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })}
|
onChange={(e) =>
|
||||||
|
updateModule({
|
||||||
|
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
value={minTimer}
|
value={minTimer}
|
||||||
className="max-w-[300px]"
|
className="max-w-[125px] min-w-[100px] w-min"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 flex-grow">
|
<div className="flex flex-col gap-3 ">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Difficulty
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
isMulti={true}
|
isMulti={true}
|
||||||
options={DIFFICULTIES.map((x) => ({
|
options={DIFFICULTIES}
|
||||||
value: x,
|
|
||||||
label: capitalize(x)
|
|
||||||
}))}
|
|
||||||
onChange={(values) => {
|
onChange={(values) => {
|
||||||
const selectedDifficulties = values ? values.map(v => v.value as Difficulty) : [];
|
const selectedDifficulties = values
|
||||||
|
? values.map((v) => v.value as Difficulty)
|
||||||
|
: [];
|
||||||
updateModule({ difficulty: selectedDifficulties });
|
updateModule({ difficulty: selectedDifficulties });
|
||||||
}}
|
}}
|
||||||
value={
|
value={
|
||||||
difficulty
|
difficulty
|
||||||
? difficulty.map(d => ({
|
? (Array.isArray(difficulty)
|
||||||
|
? difficulty
|
||||||
|
: [difficulty]
|
||||||
|
).map((d) => ({
|
||||||
value: d,
|
value: d,
|
||||||
label: capitalize(d)
|
label: capitalize(d),
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(sectionLabels.length != 0 && currentModule !== "level") ? (
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
|
<div className="flex flex-col gap-3 -xl:w-full">
|
||||||
<div className="flex flex-row gap-8">
|
<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 }) => (
|
{sectionLabels.map(({ id, label }) => (
|
||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
sectionIds.includes(id)
|
sectionIds.includes(id)
|
||||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||||
@@ -188,23 +272,43 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3 w-1/3">
|
<div className="flex flex-col gap-3 w-1/3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} />
|
Number of Parts
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="Number of Parts"
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
|
||||||
|
value={numberOfLevelParts}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-3 w-fit h-fit">
|
<div className="max-w-[200px] w-full">
|
||||||
<div className="h-6" />
|
<Select
|
||||||
<Checkbox isChecked={isPrivate} onChange={(checked) => updateModule({ isPrivate: checked })}>
|
label="Access Type"
|
||||||
Privacy (Only available for Assignments)
|
disabled={
|
||||||
</Checkbox>
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-3 w-full">
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<div className="flex flex-col gap-3 flex-grow">
|
<div className="flex flex-col gap-3 flex-grow">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Exam Label *
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Exam Label"
|
placeholder="Exam Label"
|
||||||
@@ -224,9 +328,9 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
|
|||||||
Reset Module
|
Reset Module
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-8">
|
<div className="flex flex-row gap-8 -xl:flex-col">
|
||||||
<Settings />
|
<Settings />
|
||||||
<div className="flex-grow max-w-[66%]">
|
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||||
<SectionRenderer />
|
<SectionRenderer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const DroppableQuestionArea: React.FC<DroppableQuestionAreaProps> = ({ question,
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
key={`answer_${question.id}_${answer}`}
|
key={`answer_${question.id}_${answer}`}
|
||||||
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
className={clsx("w-48 h-10 border-2 border-mti-purple-light self-center rounded-xl flex items-center justify-center", isOver && "border-mti-purple-dark")}>
|
||||||
{answer && `Paragraph ${answer}`}
|
{answer && `Paragraph ${answer}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
|
import {
|
||||||
|
activeAssignmentFilter,
|
||||||
|
futureAssignmentFilter,
|
||||||
|
} from "@/utils/assignments";
|
||||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -11,27 +14,38 @@ import Button from "../Low/Button";
|
|||||||
import ModuleBadge from "../ModuleBadge";
|
import ModuleBadge from "../ModuleBadge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assignment: Assignment
|
assignment: Assignment;
|
||||||
user: User
|
user: User;
|
||||||
session?: Session
|
session?: Session;
|
||||||
startAssignment: (assignment: Assignment) => void
|
startAssignment: (assignment: Assignment) => void;
|
||||||
resumeAssignment: (session: Session) => void
|
resumeAssignment: (session: Session) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) {
|
export default function AssignmentCard({
|
||||||
const router = useRouter()
|
user,
|
||||||
|
assignment,
|
||||||
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id])
|
session,
|
||||||
|
startAssignment,
|
||||||
|
resumeAssignment,
|
||||||
|
}: Props) {
|
||||||
|
const hasBeenSubmitted = useMemo(
|
||||||
|
() => assignment.results.map((r) => r.user).includes(user.id),
|
||||||
|
[assignment.results, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
||||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
assignment.results.map((r) => r.user).includes(user.id) &&
|
||||||
|
"border-mti-green-light"
|
||||||
)}
|
)}
|
||||||
key={assignment.id}>
|
key={assignment.id}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
<h3 className="text-mti-black/90 text-xl font-semibold">
|
||||||
|
{assignment.name}
|
||||||
|
</h3>
|
||||||
<span className="flex justify-between gap-1 text-lg">
|
<span className="flex justify-between gap-1 text-lg">
|
||||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
@@ -45,7 +59,11 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
|
|||||||
.map((e) => e.module)
|
.map((e) => e.module)
|
||||||
.sort(sortByModuleName)
|
.sort(sortByModuleName)
|
||||||
.map((module) => (
|
.map((module) => (
|
||||||
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
<ModuleBadge
|
||||||
|
className="scale-110 w-full"
|
||||||
|
key={module}
|
||||||
|
module={module}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
||||||
@@ -53,7 +71,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
|
|||||||
color="rose"
|
color="rose"
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||||
disabled
|
disabled
|
||||||
variant="outline">
|
variant="outline"
|
||||||
|
>
|
||||||
Not yet started
|
Not yet started
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -61,7 +80,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||||
data-tip="Your screen size is too small to perform an assignment">
|
data-tip="Your screen size is too small to perform an assignment"
|
||||||
|
>
|
||||||
<Button className="h-full w-full !rounded-xl" variant="outline">
|
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
@@ -71,12 +91,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
|
|||||||
data-tip="You have already started this assignment!"
|
data-tip="You have already started this assignment!"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||||
!!session && "tooltip",
|
!!session && "tooltip"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
className={clsx("w-full h-full !rounded-xl")}
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
onClick={() => startAssignment(assignment)}
|
onClick={() => startAssignment(assignment)}
|
||||||
variant="outline">
|
variant="outline"
|
||||||
|
>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,12 +107,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer"
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
className={clsx("w-full h-full !rounded-xl")}
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
onClick={() => resumeAssignment(session)}
|
onClick={() => resumeAssignment(session)}
|
||||||
color="green"
|
color="green"
|
||||||
variant="outline">
|
variant="outline"
|
||||||
|
>
|
||||||
Resume
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,11 +126,12 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
|
|||||||
color="green"
|
color="green"
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||||
disabled
|
disabled
|
||||||
variant="outline">
|
variant="outline"
|
||||||
|
>
|
||||||
Submitted
|
Submitted
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {useListSearch} from "@/hooks/useListSearch";
|
|||||||
import usePagination from "@/hooks/usePagination";
|
import usePagination from "@/hooks/usePagination";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import {ReactNode} from "react";
|
import {ReactNode} from "react";
|
||||||
import Checkbox from "../Low/Checkbox";
|
|
||||||
import Separator from "../Low/Separator";
|
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
list: T[];
|
list: T[];
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
import { useListSearch } from "@/hooks/useListSearch"
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table"
|
import {
|
||||||
import clsx from "clsx"
|
ColumnDef,
|
||||||
import { useEffect, useState } from "react"
|
flexRender,
|
||||||
import { BsArrowDown, BsArrowUp } from "react-icons/bs"
|
getCoreRowModel,
|
||||||
import Button from "../Low/Button"
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
data: T[]
|
data: T[];
|
||||||
columns: ColumnDef<any, any>[]
|
columns: ColumnDef<any, any>[];
|
||||||
searchFields: string[][]
|
searchFields: string[][];
|
||||||
size?: number
|
size?: number;
|
||||||
onDownload?: (rows: T[]) => void
|
onDownload?: (rows: T[]) => void;
|
||||||
isDownloadLoading?: boolean
|
isDownloadLoading?: boolean;
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload, isDownloadLoading, searchPlaceholder }: Props<T>) {
|
export default function Table<T>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
searchFields,
|
||||||
|
size = 16,
|
||||||
|
onDownload,
|
||||||
|
isDownloadLoading,
|
||||||
|
searchPlaceholder,
|
||||||
|
isLoading,
|
||||||
|
}: Props<T>) {
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: size,
|
pageSize: size,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { rows, renderSearch } = useListSearch<T>(searchFields, data, searchPlaceholder);
|
const { rows, renderSearch } = useListSearch<T>(
|
||||||
|
searchFields,
|
||||||
|
data,
|
||||||
|
searchPlaceholder
|
||||||
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
@@ -31,8 +53,8 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
|
|||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
state: {
|
state: {
|
||||||
pagination
|
pagination,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,16 +62,24 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
|
|||||||
<div className="w-full flex gap-2 items-end">
|
<div className="w-full flex gap-2 items-end">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
{onDownload && (
|
{onDownload && (
|
||||||
<Button isLoading={isDownloadLoading} className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
|
<Button
|
||||||
|
isLoading={isDownloadLoading}
|
||||||
|
className="w-full max-w-[200px] mb-1"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onDownload(rows)}
|
||||||
|
>
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex gap-2 justify-between items-center">
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
<div className="flex items-center gap-4 w-fit">
|
<div className="flex items-center gap-4 w-fit">
|
||||||
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>
|
<Button
|
||||||
|
className="w-[200px] h-fit"
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
>
|
||||||
Previous Page
|
Previous Page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,12 +87,16 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<div>Page</div>
|
<div>Page</div>
|
||||||
<strong>
|
<strong>
|
||||||
{table.getState().pagination.pageIndex + 1} of{' '}
|
{table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
{table.getPageCount().toLocaleString()}
|
{table.getPageCount().toLocaleString()}
|
||||||
</strong>
|
</strong>
|
||||||
<div>| Total: {table.getRowCount().toLocaleString()}</div>
|
<div>| Total: {table.getRowCount().toLocaleString()}</div>
|
||||||
</span>
|
</span>
|
||||||
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
|
<Button
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
>
|
||||||
Next Page
|
Next Page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,9 +107,17 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
|
|||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}>
|
<th
|
||||||
|
className="py-4 px-4 text-left"
|
||||||
|
key={header.id}
|
||||||
|
colSpan={header.colSpan}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')}
|
className={clsx(
|
||||||
|
header.column.getCanSort() &&
|
||||||
|
"cursor-pointer select-none",
|
||||||
|
"flex items-center gap-2"
|
||||||
|
)}
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -94,7 +136,10 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2 w-full">
|
<tbody className="px-2 w-full">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<tr
|
||||||
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -104,6 +149,17 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="min-h-screen flex justify-center items-start">
|
||||||
|
<span className="loading loading-infinity w-32" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
import usePagination from "@/hooks/usePagination";
|
import usePagination from "@/hooks/usePagination";
|
||||||
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useMemo, useState} from "react";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
|
|
||||||
const SIZE = 25;
|
const SIZE = 25;
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { checkAccess } from "@/utils/permissions";
|
|||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import useRecordStore from "@/stores/recordStore";
|
import useRecordStore from "@/stores/recordStore";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { mapBy } from "@/utils";
|
import { mapBy } from "@/utils";
|
||||||
@@ -44,13 +42,13 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
|
|
||||||
const [entity, setEntity] = useState<string>();
|
const [entity, setEntity] = useState<string>();
|
||||||
|
|
||||||
const [, setStatsUserId] = useRecordStore((state) => [
|
const [selectedUser, setStatsUserId] = useRecordStore((state) => [
|
||||||
state.selectedUser,
|
state.selectedUser,
|
||||||
state.setSelectedUser,
|
state.setSelectedUser,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const entitiesToSearch = useMemo(() => {
|
const entitiesToSearch = useMemo(() => {
|
||||||
if(entity) return entity
|
if (entity) return entity;
|
||||||
if (isAdmin) return undefined;
|
if (isAdmin) return undefined;
|
||||||
return mapBy(entities, "id");
|
return mapBy(entities, "id");
|
||||||
}, [entities, entity, isAdmin]);
|
}, [entities, entity, isAdmin]);
|
||||||
@@ -69,6 +67,14 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
"view_student_record"
|
"view_student_record"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedUserValue = useMemo(
|
||||||
|
() =>
|
||||||
|
users.find((u) => u.id === selectedUser) || {
|
||||||
|
value: user.id,
|
||||||
|
label: `${user.name} - ${user.email}`,
|
||||||
|
},
|
||||||
|
[selectedUser, user, users]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]);
|
||||||
|
|
||||||
@@ -118,10 +124,7 @@ const RecordFilter: React.FC<Props> = ({
|
|||||||
loadOptions={loadOptions}
|
loadOptions={loadOptions}
|
||||||
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
onMenuScrollToBottom={onScrollLoadMoreOptions}
|
||||||
options={users}
|
options={users}
|
||||||
defaultValue={{
|
defaultValue={selectedUserValue}
|
||||||
value: user.id,
|
|
||||||
label: `${user.name} - ${user.email}`,
|
|
||||||
}}
|
|
||||||
onChange={(value) => setStatsUserId(value?.value!)}
|
onChange={(value) => setStatsUserId(value?.value!)}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useRouter } from "next/router";
|
|||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import { getExamById } from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import { Exam, UserSolution } from "@/interfaces/exam";
|
|
||||||
import ModuleBadge from "../ModuleBadge";
|
import ModuleBadge from "../ModuleBadge";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import { findBy } from "@/utils";
|
import { findBy } from "@/utils";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
|
import { GoWorkflow } from "react-icons/go";
|
||||||
import { CiDumbbell } from "react-icons/ci";
|
import { CiDumbbell } from "react-icons/ci";
|
||||||
import { RiLogoutBoxFill } from "react-icons/ri";
|
import { RiLogoutBoxFill } from "react-icons/ri";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -120,12 +121,12 @@ export default function Sidebar({
|
|||||||
entities,
|
entities,
|
||||||
"view_statistics"
|
"view_statistics"
|
||||||
);
|
);
|
||||||
|
|
||||||
const entitiesAllowPaymentRecord = useAllowedEntities(
|
const entitiesAllowPaymentRecord = useAllowedEntities(
|
||||||
user,
|
user,
|
||||||
entities,
|
entities,
|
||||||
"view_payment_record"
|
"view_payment_record"
|
||||||
);
|
);
|
||||||
|
|
||||||
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
|
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(
|
||||||
user,
|
user,
|
||||||
entities,
|
entities,
|
||||||
@@ -147,8 +148,9 @@ export default function Sidebar({
|
|||||||
viewTickets: true,
|
viewTickets: true,
|
||||||
viewClassrooms: true,
|
viewClassrooms: true,
|
||||||
viewSettings: true,
|
viewSettings: true,
|
||||||
viewPaymentRecord: true,
|
viewPaymentRecords: true,
|
||||||
viewGeneration: true,
|
viewGeneration: true,
|
||||||
|
viewApprovalWorkflows: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sidebarPermissions: { [key: string]: boolean } = {
|
const sidebarPermissions: { [key: string]: boolean } = {
|
||||||
@@ -158,8 +160,9 @@ export default function Sidebar({
|
|||||||
viewTickets: false,
|
viewTickets: false,
|
||||||
viewClassrooms: false,
|
viewClassrooms: false,
|
||||||
viewSettings: false,
|
viewSettings: false,
|
||||||
viewPaymentRecord: false,
|
viewPaymentRecords: false,
|
||||||
viewGeneration: false,
|
viewGeneration: false,
|
||||||
|
viewApprovalWorkflows: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user || !user?.type) return sidebarPermissions;
|
if (!user || !user?.type) return sidebarPermissions;
|
||||||
@@ -197,6 +200,7 @@ export default function Sidebar({
|
|||||||
(entitiesAllowGeneration.length > 0 || isAdmin)
|
(entitiesAllowGeneration.length > 0 || isAdmin)
|
||||||
) {
|
) {
|
||||||
sidebarPermissions["viewGeneration"] = true;
|
sidebarPermissions["viewGeneration"] = true;
|
||||||
|
sidebarPermissions["viewApprovalWorkflows"] = true;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
getTypesOfUser(["agent"]).includes(user.type) &&
|
getTypesOfUser(["agent"]).includes(user.type) &&
|
||||||
@@ -231,7 +235,7 @@ export default function Sidebar({
|
|||||||
) &&
|
) &&
|
||||||
entitiesAllowPaymentRecord.length > 0
|
entitiesAllowPaymentRecord.length > 0
|
||||||
) {
|
) {
|
||||||
sidebarPermissions["viewPaymentRecord"] = true;
|
sidebarPermissions["viewPaymentRecords"] = true;
|
||||||
}
|
}
|
||||||
return sidebarPermissions;
|
return sidebarPermissions;
|
||||||
}, [
|
}, [
|
||||||
@@ -260,7 +264,7 @@ export default function Sidebar({
|
|||||||
<section
|
<section
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
isMinimized ? "w-fit" : "-xl:w-20 w-1/6",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -364,6 +368,16 @@ export default function Sidebar({
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sidebarPermissions["viewApprovalWorkflows"] && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={GoWorkflow}
|
||||||
|
label="Approval Workflows"
|
||||||
|
path={path}
|
||||||
|
keyPath="/approval-workflows"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav
|
<Nav
|
||||||
@@ -412,6 +426,16 @@ export default function Sidebar({
|
|||||||
isMinimized
|
isMinimized
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sidebarPermissions["viewPaymentRecords"] && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Payment Record"
|
||||||
|
path={path}
|
||||||
|
keyPath="/payment-record"
|
||||||
|
isMinimized
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{sidebarPermissions["viewSettings"] && (
|
{sidebarPermissions["viewSettings"] && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
@@ -432,6 +456,16 @@ export default function Sidebar({
|
|||||||
isMinimized
|
isMinimized
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sidebarPermissions["viewApprovalWorkflows"] && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={GoWorkflow}
|
||||||
|
label="Approval Workflows"
|
||||||
|
path={path}
|
||||||
|
keyPath="/approval-workflows"
|
||||||
|
isMinimized
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8 ">
|
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8 ">
|
||||||
@@ -458,7 +492,7 @@ export default function Sidebar({
|
|||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={focusMode ? () => {} : logout}
|
onClick={focusMode ? () => {} : logout}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out -xl:px-4",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function QuestionSolutionArea({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
|
"w-56 h-10 border self-center rounded-xl items-center justify-center flex gap-3 px-2",
|
||||||
!userSolution
|
!userSolution
|
||||||
? "border-mti-gray-davy"
|
? "border-mti-gray-davy"
|
||||||
: userSolution.option.toString() === question.solution.toString()
|
: userSolution.option.toString() === question.solution.toString()
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -89,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" />
|
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Reading",
|
label: "Reading",
|
||||||
value: reading,
|
value: reading || 0,
|
||||||
tooltip: "The amount of reading exams performed.",
|
tooltip: "The amount of reading exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,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" />
|
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Listening",
|
label: "Listening",
|
||||||
value: listening,
|
value: listening || 0,
|
||||||
tooltip: "The amount of listening exams performed.",
|
tooltip: "The amount of listening exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,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" />
|
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Writing",
|
label: "Writing",
|
||||||
value: writing,
|
value: writing || 0,
|
||||||
tooltip: "The amount of writing exams performed.",
|
tooltip: "The amount of writing exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -113,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" />
|
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Speaking",
|
label: "Speaking",
|
||||||
value: speaking,
|
value: speaking || 0,
|
||||||
tooltip: "The amount of speaking exams performed.",
|
tooltip: "The amount of speaking exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -121,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" />
|
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Level",
|
label: "Level",
|
||||||
value: level,
|
value: level || 0,
|
||||||
tooltip: "The amount of level exams performed.",
|
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,6 +1,5 @@
|
|||||||
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity";
|
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||||
import { Discount } from "@/interfaces/paypal";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { Code, Group, Type, User } from "@/interfaces/user";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -12,7 +11,9 @@ export default function useEntitiesUsers(type?: Type) {
|
|||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<WithLabeledEntities<User>[]>(`/api/entities/users${type ? "?type=" + type : ""}`)
|
.get<WithLabeledEntities<User>[]>(
|
||||||
|
`/api/entities/users${type ? "?type=" + type : ""}`
|
||||||
|
)
|
||||||
.then((response) => setUsers(response.data))
|
.then((response) => setUsers(response.data))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function useExams() {
|
|||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<Exam[]>("/api/exam")
|
.get<Exam[]>(`/api/exam`)
|
||||||
.then((response) => setExams(response.data))
|
.then((response) => setExams(response.data))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,71 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import {useMemo, useState} from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {BiChevronLeft} from "react-icons/bi";
|
import {
|
||||||
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs";
|
BsChevronDoubleLeft,
|
||||||
|
BsChevronDoubleRight,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsChevronRight,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import Select from "../components/Low/Select";
|
||||||
|
|
||||||
export default function usePagination<T>(list: T[], size = 25) {
|
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 itemsPerPageOptions = [25, 50, 100, 200];
|
||||||
|
|
||||||
const render = () => (
|
const render = () => (
|
||||||
<div className="w-full flex gap-2 justify-between items-center">
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
<div className="flex items-center gap-4 w-fit">
|
<div className="flex items-center gap-4 w-fit">
|
||||||
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
|
<Button
|
||||||
|
className="w-[200px] h-fit"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((prev) => prev - 1)}
|
||||||
|
>
|
||||||
Previous Page
|
Previous Page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 w-fit">
|
<div className="flex items-center gap-4 w-fit">
|
||||||
<span className="opacity-80">
|
<div className="flex flex-row items-center gap-1 w-56">
|
||||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
<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>
|
</span>
|
||||||
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}>
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||||
|
onClick={() => setPage((prev) => prev + 1)}
|
||||||
|
>
|
||||||
Next Page
|
Next Page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,27 +75,59 @@ export default function usePagination<T>(list: T[], size = 25) {
|
|||||||
const renderMinimal = () => (
|
const renderMinimal = () => (
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
<button
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<BsChevronDoubleLeft />
|
<BsChevronDoubleLeft />
|
||||||
</button>
|
</button>
|
||||||
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
<button
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((prev) => prev - 1)}
|
||||||
|
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<span className="opacity-80 w-32 text-center">
|
||||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
{page * itemsPerPage + 1} -{" "}
|
||||||
|
{itemsPerPage * (page + 1) > list.length
|
||||||
|
? list.length
|
||||||
|
: itemsPerPage * (page + 1)}
|
||||||
|
/ {list.length}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<button
|
<button
|
||||||
disabled={(page + 1) * size >= list.length}
|
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||||
onClick={() => setPage((prev) => prev + 1)}
|
onClick={() => setPage((prev) => prev + 1)}
|
||||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<BsChevronRight />
|
<BsChevronRight />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={(page + 1) * size >= list.length}
|
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||||
onClick={() => setPage(Math.floor(list.length / size))}
|
onClick={() => setPage(Math.floor(list.length / itemsPerPage))}
|
||||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<BsChevronDoubleRight />
|
<BsChevronDoubleRight />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 ".";
|
import { Module } from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
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
|
// Left easy, medium and hard to support older exam versions
|
||||||
export type BasicDifficulty = "easy" | "medium" | "hard";
|
export type BasicDifficulty = "easy" | "medium" | "hard";
|
||||||
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2";
|
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 {
|
export interface ExamBase {
|
||||||
@@ -24,8 +26,10 @@ export interface ExamBase {
|
|||||||
shuffle?: boolean;
|
shuffle?: boolean;
|
||||||
createdBy?: string; // option as it has been added later
|
createdBy?: string; // option as it has been added later
|
||||||
createdAt?: string; // option as it has been added later
|
createdAt?: string; // option as it has been added later
|
||||||
private?: boolean;
|
access: AccessType;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
requiresApproval?: boolean;
|
||||||
|
approved?: boolean;
|
||||||
}
|
}
|
||||||
export interface ReadingExam extends ExamBase {
|
export interface ReadingExam extends ExamBase {
|
||||||
module: "reading";
|
module: "reading";
|
||||||
@@ -238,6 +242,7 @@ export interface InteractiveSpeakingExercise extends Section {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FillBlanksMCOption {
|
export interface FillBlanksMCOption {
|
||||||
|
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||||
id: string;
|
id: string;
|
||||||
options: {
|
options: {
|
||||||
A: string;
|
A: string;
|
||||||
@@ -255,6 +260,7 @@ export interface FillBlanksExercise {
|
|||||||
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
||||||
allowRepetition?: boolean;
|
allowRepetition?: boolean;
|
||||||
solutions: {
|
solutions: {
|
||||||
|
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||||
id: string; // *EXAMPLE: "1"
|
id: string; // *EXAMPLE: "1"
|
||||||
solution: string; // *EXAMPLE: "preserve"
|
solution: string; // *EXAMPLE: "preserve"
|
||||||
}[];
|
}[];
|
||||||
@@ -278,6 +284,7 @@ export interface TrueFalseExercise {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TrueFalseQuestion {
|
export interface TrueFalseQuestion {
|
||||||
|
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||||
id: string; // *EXAMPLE: "1"
|
id: string; // *EXAMPLE: "1"
|
||||||
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||||
solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True"
|
solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True"
|
||||||
@@ -290,6 +297,7 @@ export interface WriteBlanksExercise {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
|
text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
|
||||||
solutions: {
|
solutions: {
|
||||||
|
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||||
id: string; // *EXAMPLE: "14"
|
id: string; // *EXAMPLE: "14"
|
||||||
solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive)
|
solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive)
|
||||||
}[];
|
}[];
|
||||||
@@ -316,12 +324,14 @@ export interface MatchSentencesExercise {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchSentenceExerciseSentence {
|
export interface MatchSentenceExerciseSentence {
|
||||||
|
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||||
id: string;
|
id: string;
|
||||||
sentence: string;
|
sentence: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchSentenceExerciseOption {
|
export interface MatchSentenceExerciseOption {
|
||||||
|
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||||
id: string;
|
id: string;
|
||||||
sentence: string;
|
sentence: string;
|
||||||
}
|
}
|
||||||
@@ -343,6 +353,7 @@ export interface MultipleChoiceExercise {
|
|||||||
|
|
||||||
export interface MultipleChoiceQuestion {
|
export interface MultipleChoiceQuestion {
|
||||||
variant: "image" | "text";
|
variant: "image" | "text";
|
||||||
|
uuid: string; // added later to fulfill the need for an immutable identifier.
|
||||||
id: string; // *EXAMPLE: "1"
|
id: string; // *EXAMPLE: "1"
|
||||||
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||||
solution: string; // *EXAMPLE: "A"
|
solution: string; // *EXAMPLE: "A"
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
||||||
|
export const ModuleTypeLabels: Record<Module, string> = {
|
||||||
|
reading: "Reading",
|
||||||
|
listening: "Listening",
|
||||||
|
writing: "Writing",
|
||||||
|
speaking: "Speaking",
|
||||||
|
level: "Level",
|
||||||
|
};
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
min: number;
|
min: number;
|
||||||
|
|||||||
@@ -170,4 +170,24 @@ export interface Code {
|
|||||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
|
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
|
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
|
||||||
|
|
||||||
|
export const userTypeLabels: Record<Type, string> = {
|
||||||
|
student: "Student",
|
||||||
|
teacher: "Teacher",
|
||||||
|
corporate: "Corporate",
|
||||||
|
admin: "Admin",
|
||||||
|
developer: "Developer",
|
||||||
|
agent: "Agent",
|
||||||
|
mastercorporate: "Master Corporate",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userTypeLabelsShort: Record<Type, string> = {
|
||||||
|
student: "",
|
||||||
|
teacher: "Prof.",
|
||||||
|
corporate: "Dir.",
|
||||||
|
admin: "Admin",
|
||||||
|
developer: "Dev.",
|
||||||
|
agent: "Agent",
|
||||||
|
mastercorporate: "Dir.",
|
||||||
|
};
|
||||||
|
|
||||||
export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T;
|
export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T;
|
||||||
|
|||||||
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 uri = process.env.MONGODB_URI || "";
|
||||||
const options = {};
|
const options = {
|
||||||
|
maxPoolSize: 10,
|
||||||
|
};
|
||||||
|
|
||||||
let client: MongoClient;
|
let client: MongoClient;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import { Type, User } from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -15,19 +13,21 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import CodeGenImportSummary, { ExcelCodegenDuplicatesMap } from "@/components/ImportSummaries/Codegen";
|
import CodeGenImportSummary, {
|
||||||
|
ExcelCodegenDuplicatesMap,
|
||||||
|
} from "@/components/ImportSummaries/Codegen";
|
||||||
import { FaFileDownload } from "react-icons/fa";
|
import { FaFileDownload } from "react-icons/fa";
|
||||||
import { IoInformationCircleOutline } from "react-icons/io5";
|
import { IoInformationCircleOutline } from "react-icons/io5";
|
||||||
import { HiOutlineDocumentText } from "react-icons/hi";
|
import { HiOutlineDocumentText } from "react-icons/hi";
|
||||||
import CodegenTable from "@/components/Tables/CodeGenTable";
|
import CodegenTable from "@/components/Tables/CodeGenTable";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
@@ -54,11 +54,26 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,22 +81,38 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchCodeGenerator({ user, users, entities = [], permissions, onFinish }: Props) {
|
export default function BatchCodeGenerator({
|
||||||
const [infos, setInfos] = useState<{ email: string; name: string; passport_id: string }[]>([]);
|
user,
|
||||||
|
users,
|
||||||
|
entities = [],
|
||||||
|
permissions,
|
||||||
|
onFinish,
|
||||||
|
}: Props) {
|
||||||
|
const [infos, setInfos] = useState<
|
||||||
|
{ email: string; name: string; passport_id: string }[]
|
||||||
|
>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined });
|
const [parsedExcel, setParsedExcel] = useState<{
|
||||||
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>();
|
rows?: any[];
|
||||||
|
errors?: any[];
|
||||||
|
}>({ rows: undefined, errors: undefined });
|
||||||
|
const [duplicatedRows, setDuplicatedRows] = useState<{
|
||||||
|
duplicates: ExcelCodegenDuplicatesMap;
|
||||||
|
count: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
@@ -94,62 +125,62 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
'First Name': {
|
"First Name": {
|
||||||
prop: 'firstName',
|
prop: "firstName",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('First Name cannot be empty')
|
throw new Error("First Name cannot be empty");
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
'Last Name': {
|
},
|
||||||
prop: 'lastName',
|
"Last Name": {
|
||||||
|
prop: "lastName",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Last Name cannot be empty')
|
throw new Error("Last Name cannot be empty");
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
'Passport/National ID': {
|
},
|
||||||
prop: 'passport_id',
|
"Passport/National ID": {
|
||||||
|
prop: "passport_id",
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validate: (value: string) => {
|
validate: (value: string) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Passport/National ID cannot be empty')
|
throw new Error("Passport/National ID cannot be empty");
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
'E-mail': {
|
},
|
||||||
prop: 'email',
|
"E-mail": {
|
||||||
|
prop: "email",
|
||||||
required: true,
|
required: true,
|
||||||
type: (value: any) => {
|
type: (value: any) => {
|
||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === "") {
|
||||||
throw new Error('Email cannot be empty')
|
throw new Error("Email cannot be empty");
|
||||||
}
|
}
|
||||||
if (!EMAIL_REGEX.test(value.trim())) {
|
if (!EMAIL_REGEX.test(value.trim())) {
|
||||||
throw new Error('Invalid Email')
|
throw new Error("Invalid Email");
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
readXlsxFile(
|
readXlsxFile(file.content, { schema, ignoreEmptyRows: false }).then(
|
||||||
file.content, { schema, ignoreEmptyRows: false })
|
(data) => {
|
||||||
.then((data) => {
|
setParsedExcel(data);
|
||||||
setParsedExcel(data)
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
@@ -164,12 +195,14 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
const duplicateRowIndices = new Set<number>();
|
const duplicateRowIndices = new Set<number>();
|
||||||
|
|
||||||
const errorRowIndices = new Set(
|
const errorRowIndices = new Set(
|
||||||
parsedExcel.errors?.map(error => error.row) || []
|
parsedExcel.errors?.map((error) => error.row) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
parsedExcel.rows.forEach((row, index) => {
|
parsedExcel.rows.forEach((row, index) => {
|
||||||
if (!errorRowIndices.has(index + 2)) {
|
if (!errorRowIndices.has(index + 2)) {
|
||||||
(Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).forEach(field => {
|
(
|
||||||
|
Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>
|
||||||
|
).forEach((field) => {
|
||||||
if (row !== null) {
|
if (row !== null) {
|
||||||
const value = row[field];
|
const value = row[field];
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -180,7 +213,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
if (existingRows) {
|
if (existingRows) {
|
||||||
existingRows.push(index + 2);
|
existingRows.push(index + 2);
|
||||||
duplicateValues.add(value);
|
duplicateValues.add(value);
|
||||||
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
|
existingRows.forEach((rowNum) =>
|
||||||
|
duplicateRowIndices.add(rowNum)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,10 +226,23 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
|
|
||||||
const info = parsedExcel.rows
|
const info = parsedExcel.rows
|
||||||
.map((row, index) => {
|
.map((row, index) => {
|
||||||
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
|
if (
|
||||||
|
errorRowIndices.has(index + 2) ||
|
||||||
|
duplicateRowIndices.has(index + 2) ||
|
||||||
|
row === null
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
|
const {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
studentID,
|
||||||
|
passport_id,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
group,
|
||||||
|
country,
|
||||||
|
} = row;
|
||||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -204,31 +252,49 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
};
|
};
|
||||||
}).filter((x) => !!x) as typeof infos;
|
})
|
||||||
|
.filter((x) => !!x) as typeof infos;
|
||||||
|
|
||||||
setInfos(info);
|
setInfos(info);
|
||||||
}
|
}
|
||||||
}, [entity, parsedExcel, type]);
|
}, [entity, parsedExcel, type]);
|
||||||
|
|
||||||
const generateAndInvite = async () => {
|
const generateAndInvite = async () => {
|
||||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
const newUsers = infos.filter(
|
||||||
|
(x) => !users.map((u) => u.email).includes(x.email)
|
||||||
|
);
|
||||||
const existingUsers = infos
|
const existingUsers = infos
|
||||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
.map((i) => users.find((u) => u.email === i.email))
|
.map((i) => users.find((u) => u.email === i.email))
|
||||||
.filter((x) => !!x && x.type === "student") as User[];
|
.filter((x) => !!x && x.type === "student") as User[];
|
||||||
|
|
||||||
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
const newUsersSentence =
|
||||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||||
|
const existingUsersSentence =
|
||||||
|
existingUsers.length > 0
|
||||||
|
? `invite ${existingUsers.length} registered student(s)`
|
||||||
|
: undefined;
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
`You are about to ${[newUsersSentence, existingUsersSentence]
|
||||||
|
.filter((x) => !!x)
|
||||||
|
.join(" and ")}, are you sure you want to continue?`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, from: user.id })))
|
Promise.all(
|
||||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
existingUsers.map(
|
||||||
|
async (u) =>
|
||||||
|
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||||
|
)
|
||||||
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -246,17 +312,20 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||||
type,
|
type,
|
||||||
codes,
|
codes,
|
||||||
infos: informations.map((info, index) => ({ ...info, code: codes[index] })),
|
infos: informations.map((info, index) => ({
|
||||||
|
...info,
|
||||||
|
code: codes[index],
|
||||||
|
})),
|
||||||
expiryDate,
|
expiryDate,
|
||||||
entity
|
entity,
|
||||||
})
|
})
|
||||||
.then(({ data, status }) => {
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
`Successfully generated${
|
||||||
type,
|
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
||||||
)} codes and they have been notified by e-mail!`,
|
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
||||||
{ toastId: "success" },
|
{ toastId: "success" }
|
||||||
);
|
);
|
||||||
|
|
||||||
onFinish();
|
onFinish();
|
||||||
@@ -287,7 +356,7 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
const fileName = "BatchCodeTemplate.xlsx";
|
const fileName = "BatchCodeTemplate.xlsx";
|
||||||
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
@@ -301,11 +370,15 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
<>
|
<>
|
||||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
|
||||||
<>
|
<>
|
||||||
<div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div>
|
<div className="flex font-bold text-xl justify-center text-gray-700">
|
||||||
|
<span>Excel File Format</span>
|
||||||
|
</div>
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} />
|
<HiOutlineDocumentText
|
||||||
|
className={`w-5 h-5 text-mti-purple-light`}
|
||||||
|
/>
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
The uploaded document must:
|
The uploaded document must:
|
||||||
</h2>
|
</h2>
|
||||||
@@ -315,15 +388,24 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
be an Excel .xlsx document.
|
be an Excel .xlsx document.
|
||||||
</li>
|
</li>
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
only have a single spreadsheet with the following <b>exact same name</b> columns:
|
only have a single spreadsheet with the following{" "}
|
||||||
|
<b>exact same name</b> columns:
|
||||||
<div className="py-4 pr-4">
|
<div className="py-4 pr-4">
|
||||||
<table className="w-full bg-white">
|
<table className="w-full bg-white">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
First Name
|
||||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Last Name
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Passport/National ID
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
E-mail
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
@@ -333,10 +415,10 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} />
|
<IoInformationCircleOutline
|
||||||
<h2 className="text-lg font-semibold">
|
className={`w-5 h-5 text-mti-purple-light`}
|
||||||
Note that:
|
/>
|
||||||
</h2>
|
<h2 className="text-lg font-semibold">Note that:</h2>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-col pl-10 gap-2">
|
<ul className="flex flex-col pl-10 gap-2">
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
@@ -346,10 +428,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
all already registered e-mails will be ignored.
|
all already registered e-mails will be ignored.
|
||||||
</li>
|
</li>
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
all rows which contain duplicate values in the columns: "Passport/National ID", "E-mail", will be ignored.
|
all rows which contain duplicate values in the columns:
|
||||||
|
"Passport/National ID", "E-mail", will be
|
||||||
|
ignored.
|
||||||
</li>
|
</li>
|
||||||
<li className="text-gray-700 list-disc">
|
<li className="text-gray-700 list-disc">
|
||||||
all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
|
all of the e-mails in the file will receive an e-mail to join
|
||||||
|
EnCoach with the role selected below.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,11 +444,21 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex justify-between mt-6 gap-8">
|
<div className="w-full flex justify-between mt-6 gap-8">
|
||||||
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => setShowHelp(false)}
|
||||||
|
variant="outline"
|
||||||
|
className="self-end w-full bg-white"
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={handleTemplateDownload}
|
||||||
|
variant="solid"
|
||||||
|
className="self-end w-full"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FaFileDownload size={24} />
|
<FaFileDownload size={24} />
|
||||||
Download Template
|
Download Template
|
||||||
@@ -375,7 +470,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</Modal>
|
</Modal>
|
||||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Choose an Excel file
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowHelp(true)}
|
onClick={() => setShowHelp(true)}
|
||||||
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
|
||||||
@@ -384,14 +481,30 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
<IoInformationCircleOutline size={24} />
|
<IoInformationCircleOutline size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
<Button
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
</Button>
|
</Button>
|
||||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{user &&
|
||||||
|
checkAccess(user, [
|
||||||
|
"developer",
|
||||||
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
Expiry Date
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={isExpiryDateEnabled}
|
||||||
|
onChange={setIsExpiryDateEnabled}
|
||||||
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
|
>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,11 +513,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
filterDate={(date) =>
|
filterDate={(date) =>
|
||||||
moment(date).isAfter(new Date()) &&
|
moment(date).isAfter(new Date()) &&
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
(user.subscriptionExpirationDate
|
||||||
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
|
: true)
|
||||||
}
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={expiryDate}
|
selected={expiryDate}
|
||||||
@@ -414,41 +529,67 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Entity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
defaultValue={{
|
||||||
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Select the type of user they should be
|
||||||
|
</label>
|
||||||
{user && (
|
{user && (
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
>
|
||||||
.filter((x) => {
|
{Object.keys(USER_TYPE_LABELS).reduce((acc, x) => {
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
})
|
acc.push(
|
||||||
.map((type) => (
|
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
return acc;
|
||||||
|
}, [] as JSX.Element[])}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>}
|
{infos.length > 0 && (
|
||||||
|
<CodeGenImportSummary
|
||||||
|
infos={infos}
|
||||||
|
parsedExcel={parsedExcel}
|
||||||
|
duplicateRows={duplicatedRows}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{infos.length !== 0 && (
|
{infos.length !== 0 && (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span>
|
<span className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Codes will be sent to:
|
||||||
|
</span>
|
||||||
<CodegenTable infos={infos} />
|
<CodegenTable infos={infos} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
{checkAccess(
|
||||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
user,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"createCodes"
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
onClick={generateAndInvite}
|
||||||
|
disabled={
|
||||||
|
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||||
|
}
|
||||||
|
>
|
||||||
Generate & Send
|
Generate & Send
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
import { Type, User } from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -13,10 +12,8 @@ import { toast } from "react-toastify";
|
|||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
@@ -43,30 +40,52 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CodeGenerator({ user, entities = [], permissions, onFinish }: Props) {
|
export default function CodeGenerator({
|
||||||
|
user,
|
||||||
|
entities = [],
|
||||||
|
permissions,
|
||||||
|
onFinish,
|
||||||
|
}: Props) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
|
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
@@ -105,11 +124,18 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
User Code Generator
|
||||||
|
</label>
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Entity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
defaultValue={{
|
||||||
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
@@ -121,25 +147,33 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
>
|
||||||
.filter((x) => {
|
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
})
|
acc.push(x);
|
||||||
.map((type) => (
|
return acc;
|
||||||
<option key={type} value={type}>
|
}, [])}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{checkAccess(user, [
|
||||||
|
"developer",
|
||||||
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
Expiry Date
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={isExpiryDateEnabled}
|
||||||
|
onChange={setIsExpiryDateEnabled}
|
||||||
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
|
>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,11 +182,13 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
filterDate={(date) =>
|
filterDate={(date) =>
|
||||||
moment(date).isAfter(new Date()) &&
|
moment(date).isAfter(new Date()) &&
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
(user.subscriptionExpirationDate
|
||||||
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
|
: true)
|
||||||
}
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={expiryDate}
|
selected={expiryDate}
|
||||||
@@ -161,25 +197,40 @@ export default function CodeGenerator({ user, entities = [], permissions, onFini
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
{checkAccess(
|
||||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
user,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"createCodes"
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
onClick={() => generateCode(type)}
|
||||||
|
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
||||||
|
>
|
||||||
Generate
|
Generate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Generated Code:
|
||||||
|
</label>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
data-tip="Click to copy"
|
data-tip="Click to copy"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{generatedCode}
|
{generatedCode}
|
||||||
</div>
|
</div>
|
||||||
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
{generatedCode && (
|
||||||
|
<span className="text-sm text-mti-gray-dim font-light">
|
||||||
|
Give this code to the user to complete their registration
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import Separator from "@/components/Low/Separator";
|
|||||||
import { Grading, Step } from "@/interfaces";
|
import { Grading, Step } from "@/interfaces";
|
||||||
import { Entity } from "@/interfaces/entity";
|
import { Entity } from "@/interfaces/entity";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading";
|
import {
|
||||||
import { mapBy } from "@/utils";
|
CEFR_STEPS,
|
||||||
|
GENERAL_STEPS,
|
||||||
|
IELTS_STEPS,
|
||||||
|
TOFEL_STEPS,
|
||||||
|
} from "@/resources/grading";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Divider } from "primereact/divider";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
Dispatch,
|
||||||
|
memo,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { BsPlusCircle, BsTrash } from "react-icons/bs";
|
import { BsPlusCircle, BsTrash } from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
@@ -27,30 +37,149 @@ const areStepsOverlapped = (steps: Step[]) => {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
interface RowProps {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
isLast: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
setSteps: Dispatch<SetStateAction<Step[]>>;
|
||||||
|
addRow: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradingRow({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
label,
|
||||||
|
index,
|
||||||
|
isLoading,
|
||||||
|
isLast,
|
||||||
|
setSteps,
|
||||||
|
addRow,
|
||||||
|
}: RowProps) {
|
||||||
|
const onChangeMin = useCallback(
|
||||||
|
(e: string) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChangeMax = useCallback(
|
||||||
|
(e: string) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChangeLabel = useCallback(
|
||||||
|
(e: string) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((x, i) => (i === index ? { ...x, label: e } : x))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAddRow = useCallback(() => addRow(index), [addRow, index]);
|
||||||
|
|
||||||
|
const removeRow = useCallback(
|
||||||
|
() => setSteps((prev) => prev.filter((_, i) => i !== index)),
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 w-full">
|
||||||
|
<Input
|
||||||
|
label="Min. Percentage"
|
||||||
|
value={min}
|
||||||
|
type="number"
|
||||||
|
disabled={index === 0 || isLoading}
|
||||||
|
onChange={onChangeMin}
|
||||||
|
name="min"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Grade"
|
||||||
|
value={label}
|
||||||
|
type="text"
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={onChangeLabel}
|
||||||
|
name="min"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Max. Percentage"
|
||||||
|
value={max}
|
||||||
|
type="number"
|
||||||
|
disabled={isLast || isLoading}
|
||||||
|
onChange={onChangeMax}
|
||||||
|
name="max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{index !== 0 && !isLast && (
|
||||||
|
<button
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pt-9 text-xl group"
|
||||||
|
onClick={removeRow}
|
||||||
|
>
|
||||||
|
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
|
||||||
|
<BsTrash />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLast && (
|
||||||
|
<Button
|
||||||
|
className="w-full flex items-center justify-center"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onAddRow}
|
||||||
|
>
|
||||||
|
<BsPlusCircle />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const GradingRowMemo = memo(GradingRow);
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
entitiesGrading: Grading[];
|
entitiesGrading: Grading[];
|
||||||
entities: Entity[]
|
entities: Entity[];
|
||||||
mutate: () => void
|
mutate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) {
|
export default function CorporateGradingSystem({
|
||||||
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined)
|
user,
|
||||||
|
entitiesGrading = [],
|
||||||
|
entities = [],
|
||||||
|
mutate,
|
||||||
|
}: Props) {
|
||||||
|
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [steps, setSteps] = useState<Step[]>([]);
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
const [otherEntities, setOtherEntities] = useState<string[]>([])
|
const [otherEntities, setOtherEntities] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (entity) {
|
if (entity) {
|
||||||
const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps
|
const entitySteps = entitiesGrading.find(
|
||||||
setSteps(entitySteps || [])
|
(e) => e.entity === entity
|
||||||
|
)!.steps;
|
||||||
|
setSteps(entitySteps || []);
|
||||||
}
|
}
|
||||||
}, [entitiesGrading, entity])
|
}, [entitiesGrading, entity]);
|
||||||
|
|
||||||
const saveGradingSystem = () => {
|
const saveGradingSystem = () => {
|
||||||
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold.");
|
if (!steps.every((x) => x.min < x.max))
|
||||||
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
return toast.error(
|
||||||
|
"One of your steps has a minimum threshold inferior to its superior threshold."
|
||||||
|
);
|
||||||
|
if (areStepsOverlapped(steps))
|
||||||
|
return toast.error("There seems to be an overlap in one of your steps.");
|
||||||
if (
|
if (
|
||||||
steps.reduce((acc, curr) => {
|
steps.reduce((acc, curr) => {
|
||||||
return acc - (curr.max - curr.min + 1);
|
return acc - (curr.max - curr.min + 1);
|
||||||
@@ -68,8 +197,12 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyToOtherEntities = () => {
|
const applyToOtherEntities = () => {
|
||||||
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold.");
|
if (!steps.every((x) => x.min < x.max))
|
||||||
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
return toast.error(
|
||||||
|
"One of your steps has a minimum threshold inferior to its superior threshold."
|
||||||
|
);
|
||||||
|
if (areStepsOverlapped(steps))
|
||||||
|
return toast.error("There seems to be an overlap in one of your steps.");
|
||||||
if (
|
if (
|
||||||
steps.reduce((acc, curr) => {
|
steps.reduce((acc, curr) => {
|
||||||
return acc - (curr.max - curr.min + 1);
|
return acc - (curr.max - curr.min + 1);
|
||||||
@@ -77,24 +210,51 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
|
|||||||
)
|
)
|
||||||
return toast.error("There seems to be an open interval in your steps.");
|
return toast.error("There seems to be an open interval in your steps.");
|
||||||
|
|
||||||
if (otherEntities.length === 0) return toast.error("Select at least one entity")
|
if (otherEntities.length === 0)
|
||||||
|
return toast.error("Select at least one entity");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post("/api/grading/multiple", { user: user.id, entities: otherEntities, steps })
|
.post("/api/grading/multiple", {
|
||||||
|
user: user.id,
|
||||||
|
entities: otherEntities,
|
||||||
|
steps,
|
||||||
|
})
|
||||||
.then(() => toast.success("Your grading system has been saved!"))
|
.then(() => toast.success("Your grading system has been saved!"))
|
||||||
.then(mutate)
|
.then(mutate)
|
||||||
.catch(() => toast.error("Something went wrong, please try again later"))
|
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addRow = useCallback((index: number) => {
|
||||||
|
setSteps((prev) => {
|
||||||
|
const item = {
|
||||||
|
min: prev[index === 0 ? 0 : index - 1].max + 1,
|
||||||
|
max: prev[index + 1].min - 1,
|
||||||
|
label: "",
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
...prev.slice(0, index + 1),
|
||||||
|
item,
|
||||||
|
...prev.slice(index + 1, prev.length),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Grading System</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Grading System
|
||||||
|
</label>
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Entity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
defaultValue={{
|
||||||
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
@@ -104,20 +264,33 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
|
|||||||
{entities.length > 1 && (
|
{entities.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Apply this grading system to other entities</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Copy this grading system to other entities
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))}
|
onChange={(e) =>
|
||||||
|
!e
|
||||||
|
? setOtherEntities([])
|
||||||
|
: setOtherEntities(e.map((o) => o.value!))
|
||||||
|
}
|
||||||
isMulti
|
isMulti
|
||||||
/>
|
/>
|
||||||
<Button onClick={applyToOtherEntities} isLoading={isLoading} disabled={isLoading || otherEntities.length === 0} variant="outline">
|
<Button
|
||||||
Apply to {otherEntities.length} other entities
|
onClick={applyToOtherEntities}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading || otherEntities.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Copy to {otherEntities.length} other entities
|
||||||
</Button>
|
</Button>
|
||||||
<Separator />
|
<Separator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Preset Systems
|
||||||
|
</label>
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
||||||
CEFR
|
CEFR
|
||||||
@@ -134,62 +307,26 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<>
|
<GradingRowMemo
|
||||||
<div className="flex items-center gap-4">
|
key={index}
|
||||||
<div className="grid grid-cols-3 gap-4 w-full" key={step.min}>
|
min={step.min}
|
||||||
<Input
|
max={step.max}
|
||||||
label="Min. Percentage"
|
label={step.label}
|
||||||
value={step.min}
|
index={index}
|
||||||
type="number"
|
isLoading={isLoading}
|
||||||
disabled={index === 0 || isLoading}
|
isLast={index === steps.length - 1}
|
||||||
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x)))}
|
setSteps={setSteps}
|
||||||
name="min"
|
addRow={addRow}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
label="Grade"
|
|
||||||
value={step.label}
|
|
||||||
type="text"
|
|
||||||
disabled={isLoading}
|
|
||||||
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, label: e } : x)))}
|
|
||||||
name="min"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Max. Percentage"
|
|
||||||
value={step.max}
|
|
||||||
type="number"
|
|
||||||
disabled={index === steps.length - 1 || isLoading}
|
|
||||||
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x)))}
|
|
||||||
name="max"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{index !== 0 && index !== steps.length - 1 && (
|
|
||||||
<button
|
|
||||||
disabled={isLoading}
|
|
||||||
className="pt-9 text-xl group"
|
|
||||||
onClick={() => setSteps((prev) => prev.filter((_, i) => i !== index))}>
|
|
||||||
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
|
|
||||||
<BsTrash />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{index < steps.length - 1 && (
|
|
||||||
<Button
|
|
||||||
className="w-full flex items-center justify-center"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => {
|
|
||||||
const item = { min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: "" };
|
|
||||||
setSteps((prev) => [...prev.slice(0, index + 1), item, ...prev.slice(index + 1, steps.length)]);
|
|
||||||
}}>
|
|
||||||
<BsPlusCircle />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button onClick={saveGradingSystem} isLoading={isLoading} disabled={isLoading} className="mt-8">
|
<Button
|
||||||
Save Grading System
|
onClick={saveGradingSystem}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mt-8"
|
||||||
|
>
|
||||||
|
Save Changes to entities
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize, uniqBy } from "lodash";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
|
|||||||
@@ -15,26 +15,41 @@ import { findBy, mapBy } from "@/utils";
|
|||||||
import useEntitiesCodes from "@/hooks/useEntitiesCodes";
|
import useEntitiesCodes from "@/hooks/useEntitiesCodes";
|
||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
|
|
||||||
type TableData = Code & { entity?: EntityWithRoles, creator?: User }
|
type TableData = Code & { entity?: EntityWithRoles; creator?: User };
|
||||||
const columnHelper = createColumnHelper<TableData>();
|
const columnHelper = createColumnHelper<TableData>();
|
||||||
|
|
||||||
export default function CodeList({ user, entities, canDeleteCodes }
|
export default function CodeList({
|
||||||
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) {
|
user,
|
||||||
|
entities,
|
||||||
|
canDeleteCodes,
|
||||||
|
}: {
|
||||||
|
user: User;
|
||||||
|
entities: EntityWithRoles[];
|
||||||
|
canDeleteCodes?: boolean;
|
||||||
|
}) {
|
||||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities])
|
const entityIDs = useMemo(() => mapBy(entities, "id"), [entities]);
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs)
|
const { codes, reload, isLoading } = useEntitiesCodes(
|
||||||
|
isAdmin(user) ? undefined : entityIDs
|
||||||
|
);
|
||||||
|
|
||||||
const data: TableData[] = useMemo(() => codes.map((code) => ({
|
const data: TableData[] = useMemo(
|
||||||
|
() =>
|
||||||
|
codes.map((code) => ({
|
||||||
...code,
|
...code,
|
||||||
entity: findBy(entities, 'id', code.entity),
|
entity: findBy(entities, "id", code.entity),
|
||||||
creator: findBy(users, 'id', code.creator)
|
creator: findBy(users, "id", code.creator),
|
||||||
})) as TableData[], [codes, entities, users])
|
})) as TableData[],
|
||||||
|
[codes, entities, users]
|
||||||
|
);
|
||||||
|
|
||||||
const toggleCode = (id: string) => {
|
const toggleCode = (id: string) => {
|
||||||
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setSelectedCodes((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// const toggleAllCodes = (checked: boolean) => {
|
// const toggleAllCodes = (checked: boolean) => {
|
||||||
@@ -44,8 +59,11 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
const deleteCodes = async (codes: string[]) => {
|
const deleteCodes = async (codes: string[]) => {
|
||||||
if (!canDeleteCodes) return
|
if (!canDeleteCodes) return;
|
||||||
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
|
if (
|
||||||
|
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
codes.forEach((code) => params.append("code", code));
|
codes.forEach((code) => params.append("code", code));
|
||||||
@@ -73,8 +91,9 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteCode = async (code: Code) => {
|
const deleteCode = async (code: Code) => {
|
||||||
if (!canDeleteCodes) return
|
if (!canDeleteCodes) return;
|
||||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
|
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
|
||||||
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/code/${code.code}`)
|
.delete(`/api/code/${code.code}`)
|
||||||
@@ -99,10 +118,13 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
columnHelper.accessor("code", {
|
columnHelper.accessor("code", {
|
||||||
id: "codeCheckbox",
|
id: "codeCheckbox",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => (""),
|
header: () => "",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!info.row.original.userId ? (
|
!info.row.original.userId ? (
|
||||||
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
|
<Checkbox
|
||||||
|
isChecked={selectedCodes.includes(info.getValue())}
|
||||||
|
onChange={() => toggleCode(info.getValue())}
|
||||||
|
>
|
||||||
{""}
|
{""}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
) : null,
|
) : null,
|
||||||
@@ -113,7 +135,8 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("creationDate", {
|
columnHelper.accessor("creationDate", {
|
||||||
header: "Creation Date",
|
header: "Creation Date",
|
||||||
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
|
cell: (info) =>
|
||||||
|
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("email", {
|
columnHelper.accessor("email", {
|
||||||
header: "E-mail",
|
header: "E-mail",
|
||||||
@@ -121,7 +144,12 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("creator", {
|
columnHelper.accessor("creator", {
|
||||||
header: "Creator",
|
header: "Creator",
|
||||||
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A",
|
cell: (info) =>
|
||||||
|
info.getValue()
|
||||||
|
? `${info.getValue().name} (${
|
||||||
|
USER_TYPE_LABELS[info.getValue().type]
|
||||||
|
})`
|
||||||
|
: "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("entity", {
|
columnHelper.accessor("entity", {
|
||||||
header: "Entity",
|
header: "Entity",
|
||||||
@@ -147,7 +175,11 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{canDeleteCodes && !row.original.userId && (
|
{canDeleteCodes && !row.original.userId && (
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
|
<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" />
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -168,7 +200,8 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
color="red"
|
color="red"
|
||||||
className="!py-1 px-10"
|
className="!py-1 px-10"
|
||||||
onClick={() => deleteCodes(selectedCodes)}>
|
onClick={() => deleteCodes(selectedCodes)}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +210,14 @@ export default function CodeList({ user, entities, canDeleteCodes }
|
|||||||
<Table<TableData>
|
<Table<TableData>
|
||||||
data={data}
|
data={data}
|
||||||
columns={defaultColumns}
|
columns={defaultColumns}
|
||||||
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]}
|
isLoading={isLoading}
|
||||||
|
searchFields={[
|
||||||
|
["code"],
|
||||||
|
["email"],
|
||||||
|
["entity", "label"],
|
||||||
|
["creator", "name"],
|
||||||
|
["creator", "type"],
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,26 +4,28 @@ import useExams from "@/hooks/useExams";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { Type, User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import { getExamById } from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
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 axios from "axios";
|
||||||
import clsx from "clsx";
|
import { capitalize } from "lodash";
|
||||||
import { capitalize, uniq } from "lodash";
|
|
||||||
import { useRouter } from "next/router";
|
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 { toast } from "react-toastify";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
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 { BiEdit } from "react-icons/bi";
|
||||||
|
import { findBy, mapBy } from "@/utils";
|
||||||
|
|
||||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||||
|
|
||||||
@@ -34,48 +36,45 @@ const CLASSES: { [key in Module]: string } = {
|
|||||||
writing: "text-ielts-writing",
|
writing: "text-ielts-writing",
|
||||||
level: "text-ielts-level",
|
level: "text-ielts-level",
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
const ExamOwnerSelector = ({ options, exam, onSave }: { options: User[]; exam: Exam; onSave: (owners: string[]) => void }) => {
|
export default function ExamList({
|
||||||
const [owners, setOwners] = useState(exam.owners || []);
|
user,
|
||||||
|
entities,
|
||||||
return (
|
}: {
|
||||||
<div className="w-full flex flex-col gap-4">
|
user: User;
|
||||||
<div className="grid grid-cols-4 mt-4">
|
entities: EntityWithRoles[];
|
||||||
{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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ExamList({ user, entities }: { user: User; entities: EntityWithRoles[]; }) {
|
|
||||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||||
|
|
||||||
const { exams, reload } = useExams();
|
const canViewConfidentialEntities = useMemo(
|
||||||
|
() =>
|
||||||
|
mapBy(
|
||||||
|
findAllowedEntities(user, entities, "view_confidential_exams"),
|
||||||
|
"id"
|
||||||
|
),
|
||||||
|
[user, entities]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { exams, reload, isLoading } = useExams();
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
|
// Pass this permission filter to the backend later
|
||||||
|
const filteredExams = useMemo(
|
||||||
const filteredExams = useMemo(() => exams.filter((e) => {
|
() =>
|
||||||
if (!e.private) return true
|
["admin", "developer"].includes(user?.type)
|
||||||
return (e.owners || []).includes(user?.id || "")
|
? exams
|
||||||
}), [exams, user?.id])
|
: exams.filter((item) => {
|
||||||
|
if (
|
||||||
const filteredCorporates = useMemo(() => {
|
item.access === "confidential" &&
|
||||||
const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id);
|
!canViewConfidentialEntities.find((x) =>
|
||||||
return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate");
|
(item.entities ?? []).includes(x)
|
||||||
}, [users, groups, user]);
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[canViewConfidentialEntities, exams, user?.type]
|
||||||
|
);
|
||||||
|
|
||||||
const parsedExams = useMemo(() => {
|
const parsedExams = useMemo(() => {
|
||||||
return filteredExams.map((exam) => {
|
return filteredExams.map((exam) => {
|
||||||
@@ -93,7 +92,10 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
});
|
});
|
||||||
}, [filteredExams, users]);
|
}, [filteredExams, users]);
|
||||||
|
|
||||||
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(searchFields, parsedExams);
|
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
|
||||||
|
searchFields,
|
||||||
|
parsedExams
|
||||||
|
);
|
||||||
|
|
||||||
const dispatch = useExamStore((state) => state.dispatch);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
|
|
||||||
@@ -102,19 +104,33 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
const loadExam = async (module: Module, examId: string) => {
|
const loadExam = async (module: Module, examId: string) => {
|
||||||
const exam = await getExamById(module, examId.trim());
|
const exam = await getExamById(module, examId.trim());
|
||||||
if (!exam) {
|
if (!exam) {
|
||||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
toast.error(
|
||||||
|
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
||||||
|
{
|
||||||
toastId: "invalid-exam-id",
|
toastId: "invalid-exam-id",
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: "INIT_EXAM", payload: { exams: [exam], modules: [module] } })
|
dispatch({
|
||||||
|
type: "INIT_EXAM",
|
||||||
|
payload: { exams: [exam], modules: [module] },
|
||||||
|
});
|
||||||
|
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
const privatizeExam = async (exam: Exam) => {
|
const privatizeExam = async (exam: Exam) => {
|
||||||
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
|
||||||
|
exam.access
|
||||||
|
}?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
||||||
@@ -134,32 +150,15 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
const updateExam = async (exam: Exam, body: object) => {
|
|
||||||
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
.finally(() => setSelectedExam(undefined));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
const deleteExam = async (exam: Exam) => {
|
||||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||||
@@ -181,8 +180,12 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTotalExercises = (exam: Exam) => {
|
const getTotalExercises = (exam: Exam) => {
|
||||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
if (
|
||||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
exam.module === "reading" ||
|
||||||
|
exam.module === "listening" ||
|
||||||
|
exam.module === "level"
|
||||||
|
) {
|
||||||
|
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
|
||||||
}
|
}
|
||||||
|
|
||||||
return countExercises(exam.exercises);
|
return countExercises(exam.exercises);
|
||||||
@@ -195,7 +198,11 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("module", {
|
columnHelper.accessor("module", {
|
||||||
header: "Module",
|
header: "Module",
|
||||||
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
cell: (info) => (
|
||||||
|
<span className={CLASSES[info.getValue()]}>
|
||||||
|
{capitalize(info.getValue())}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||||
header: "Exercises",
|
header: "Exercises",
|
||||||
@@ -205,9 +212,9 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
header: "Timer",
|
header: "Timer",
|
||||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("private", {
|
columnHelper.accessor("access", {
|
||||||
header: "Private",
|
header: "Access",
|
||||||
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
|
cell: (info) => <span>{capitalize(info.getValue())}</span>,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("createdAt", {
|
columnHelper.accessor("createdAt", {
|
||||||
header: "Created At",
|
header: "Created At",
|
||||||
@@ -222,7 +229,10 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("createdBy", {
|
columnHelper.accessor("createdBy", {
|
||||||
header: "Created By",
|
header: "Created By",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) =>
|
||||||
|
!info.getValue()
|
||||||
|
? "System"
|
||||||
|
: findBy(users, "id", info.getValue())?.name || "N/A",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
@@ -230,16 +240,19 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
cell: ({ row }: { row: { original: Exam } }) => {
|
cell: ({ row }: { row: { original: Exam } }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
{(row.original.owners?.includes(user.id) ||
|
||||||
|
checkAccess(user, ["admin", "developer"])) && (
|
||||||
<>
|
<>
|
||||||
|
{checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
<button
|
<button
|
||||||
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
data-tip="Edit exam"
|
||||||
onClick={async () => await privatizeExam(row.original)}
|
onClick={() => setSelectedExam(row.original)}
|
||||||
className="cursor-pointer tooltip">
|
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 />
|
<BsPencil />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -248,11 +261,18 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
<button
|
<button
|
||||||
data-tip="Load exam"
|
data-tip="Load exam"
|
||||||
className="cursor-pointer tooltip"
|
className="cursor-pointer tooltip"
|
||||||
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
onClick={async () =>
|
||||||
|
await loadExam(row.original.module, row.original.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</button>
|
</button>
|
||||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
<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" />
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -269,26 +289,32 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleExamEdit = () => {
|
const handleExamEdit = () => {
|
||||||
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
|
router.push(
|
||||||
}
|
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl">
|
<Modal
|
||||||
|
isOpen={!!selectedExam}
|
||||||
|
onClose={() => setSelectedExam(undefined)}
|
||||||
|
maxWidth="max-w-xl"
|
||||||
|
>
|
||||||
{!!selectedExam ? (
|
{!!selectedExam ? (
|
||||||
<>
|
<>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<BiEdit className="w-5 h-5 text-gray-600" />
|
<BiEdit className="w-5 h-5 text-gray-600" />
|
||||||
<span className="text-gray-600 font-medium">Ready to Edit</span>
|
<span className="text-gray-600 font-medium">
|
||||||
|
Ready to Edit
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||||
<p className="font-medium mb-1">
|
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
||||||
Exam ID: {selectedExam.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
@@ -326,7 +352,12 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="p-4 text-left" key={header.id}>
|
<th className="p-4 text-left" key={header.id}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -334,7 +365,10 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<tr
|
||||||
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -344,6 +378,17 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import { Group, User } from "@/interfaces/user";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
import { CorporateUser, Group, User } from "@/interfaces/user";
|
|
||||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize, uniq } from "lodash";
|
import { uniq } from "lodash";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import { useFilePicker } from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import { getUserCorporate } from "@/utils/groups";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user";
|
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
|
||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||||
import { WithEntity } from "@/interfaces/entity";
|
import { WithEntity } from "@/interfaces/entity";
|
||||||
|
|
||||||
const searchFields = [["name"]];
|
const searchFields = [["name"]];
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
const columnHelper = createColumnHelper<WithEntity<Group>>();
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
interface CreateDialogProps {
|
interface CreateDialogProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -35,9 +34,13 @@ interface CreateDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
const [name, setName] = useState<string | undefined>(
|
||||||
|
group?.name || undefined
|
||||||
|
);
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
const [participants, setParticipants] = useState<string[]>(
|
||||||
|
group?.participants || []
|
||||||
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
@@ -47,9 +50,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const availableUsers = useMemo(() => {
|
const availableUsers = useMemo(() => {
|
||||||
if (user?.type === "teacher") return users.filter((x) => ["student"].includes(x.type));
|
if (user?.type === "teacher")
|
||||||
if (user?.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type));
|
return users.filter((x) => ["student"].includes(x.type));
|
||||||
if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type));
|
if (user?.type === "corporate")
|
||||||
|
return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||||
|
if (user?.type === "mastercorporate")
|
||||||
|
return users.filter((x) =>
|
||||||
|
["corporate", "teacher", "student"].includes(x.type)
|
||||||
|
);
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}, [user, users]);
|
}, [user, users]);
|
||||||
@@ -64,9 +72,12 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [email] = row as string[];
|
const [email] = row as string[];
|
||||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
return EMAIL_REGEX.test(email) &&
|
||||||
|
!users.map((u) => u.email).includes(email)
|
||||||
|
? email.toString().trim()
|
||||||
|
: undefined;
|
||||||
})
|
})
|
||||||
.filter((x) => !!x),
|
.filter((x) => !!x)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
@@ -76,12 +87,17 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
const emailUsers = [...new Set(emails)]
|
||||||
|
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||||
|
.filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
|
((user.type === "developer" ||
|
||||||
|
user.type === "admin" ||
|
||||||
|
user.type === "corporate" ||
|
||||||
|
user.type === "mastercorporate") &&
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student")
|
||||||
);
|
);
|
||||||
|
|
||||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||||
@@ -89,7 +105,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
user.type !== "teacher"
|
user.type !== "teacher"
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
: "Added all students found in the file you've provided!",
|
: "Added all students found in the file you've provided!",
|
||||||
{ toastId: "upload-success" },
|
{ toastId: "upload-success" }
|
||||||
);
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -100,15 +116,27 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
|
if (
|
||||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
name !== group?.name &&
|
||||||
|
(name?.trim() === "Students" ||
|
||||||
|
name?.trim() === "Teachers" ||
|
||||||
|
name?.trim() === "Corporate")
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"That group name is reserved and cannot be used, please enter another one."
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants })
|
(group ? axios.patch : axios.post)(
|
||||||
|
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||||
|
{ name, admin, participants }
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
toast.success(
|
||||||
|
`Group "${name}" ${group ? "edited" : "created"} successfully`
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -121,30 +149,58 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
availableUsers.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.email} - ${x.name}`,
|
||||||
|
})),
|
||||||
|
[availableUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() =>
|
||||||
|
participants.map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: `${users.find((y) => y.id === x)?.email} - ${
|
||||||
|
users.find((y) => y.id === x)?.name
|
||||||
|
}`,
|
||||||
|
})),
|
||||||
|
[participants, users]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
<Input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label="Name"
|
||||||
|
defaultValue={name}
|
||||||
|
onChange={setName}
|
||||||
|
required
|
||||||
|
disabled={group?.disableEditing}
|
||||||
|
/>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
Participants
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||||
|
>
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full gap-8">
|
<div className="flex w-full gap-8">
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={participants.map((x) => ({
|
value={value}
|
||||||
value: x,
|
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
placeholder="Participants..."
|
placeholder="Participants..."
|
||||||
defaultValue={participants.map((x) => ({
|
defaultValue={value}
|
||||||
value: x,
|
options={userOptions}
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
options={availableUsers.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
isMulti
|
isMulti
|
||||||
isSearchable
|
isSearchable
|
||||||
@@ -160,18 +216,36 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
<Button
|
||||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
className="w-full max-w-[300px] h-fit"
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{filesContent.length === 0
|
||||||
|
? "Upload participants Excel file"
|
||||||
|
: filesContent[0].name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={submit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!name}
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +256,8 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
export default function GroupList({ user }: { user: User }) {
|
export default function GroupList({ user }: { user: User }) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
const [viewingAllParticipants, setViewingAllParticipants] =
|
||||||
|
useState<string>();
|
||||||
|
|
||||||
const { permissions } = usePermissions(user?.id || "");
|
const { permissions } = usePermissions(user?.id || "");
|
||||||
|
|
||||||
@@ -211,7 +286,14 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
columnHelper.accessor("admin", {
|
columnHelper.accessor("admin", {
|
||||||
header: "Admin",
|
header: "Admin",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip={
|
||||||
|
USER_TYPE_LABELS[
|
||||||
|
users.find((x) => x.id === info.getValue())?.type || "student"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -226,20 +308,27 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
<span>
|
<span>
|
||||||
{info
|
{info
|
||||||
.getValue()
|
.getValue()
|
||||||
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
.slice(
|
||||||
|
0,
|
||||||
|
viewingAllParticipants === info.row.original.id ? undefined : 5
|
||||||
|
)
|
||||||
.map((x) => users.find((y) => y.id === x)?.name)
|
.map((x) => users.find((y) => y.id === x)?.name)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
|
{info.getValue().length > 5 &&
|
||||||
|
viewingAllParticipants !== info.row.original.id && (
|
||||||
<button
|
<button
|
||||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
onClick={() => setViewingAllParticipants(info.row.original.id)}>
|
onClick={() => setViewingAllParticipants(info.row.original.id)}
|
||||||
|
>
|
||||||
, View More
|
, View More
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
|
{info.getValue().length > 5 &&
|
||||||
|
viewingAllParticipants === info.row.original.id && (
|
||||||
<button
|
<button
|
||||||
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
onClick={() => setViewingAllParticipants(undefined)}>
|
onClick={() => setViewingAllParticipants(undefined)}
|
||||||
|
>
|
||||||
, View Less
|
, View Less
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -252,15 +341,29 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
cell: ({ row }: { row: { original: Group } }) => {
|
cell: ({ row }: { row: { original: Group } }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
{user &&
|
||||||
|
(checkAccess(user, ["developer", "admin"]) ||
|
||||||
|
user.id === row.original.admin) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
|
{(!row.original.disableEditing ||
|
||||||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
checkAccess(user, ["developer", "admin"]),
|
||||||
|
"editGroup") && (
|
||||||
|
<div
|
||||||
|
data-tip="Edit"
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
onClick={() => setEditingGroup(row.original)}
|
||||||
|
>
|
||||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
|
{(!row.original.disableEditing ||
|
||||||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
checkAccess(user, ["developer", "admin"]),
|
||||||
|
"deleteGroup") && (
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
onClick={() => deleteGroup(row.original)}
|
||||||
|
>
|
||||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -280,7 +383,11 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
||||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
<Modal
|
||||||
|
isOpen={isCreating || !!editingGroup}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||||
|
>
|
||||||
<CreatePanel
|
<CreatePanel
|
||||||
group={editingGroup}
|
group={editingGroup}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -288,12 +395,22 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
users={users}
|
users={users}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Table data={groups} columns={defaultColumns} searchFields={searchFields} />
|
<Table
|
||||||
|
data={groups}
|
||||||
|
columns={defaultColumns}
|
||||||
|
searchFields={searchFields}
|
||||||
|
/>
|
||||||
|
|
||||||
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
|
{checkAccess(
|
||||||
|
user,
|
||||||
|
["teacher", "corporate", "mastercorporate", "admin", "developer"],
|
||||||
|
permissions,
|
||||||
|
"createGroup"
|
||||||
|
) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||||
|
>
|
||||||
New Group
|
New Group
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import usePackages from "@/hooks/usePackages";
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { Package } from "@/interfaces/paypal";
|
import { Package } from "@/interfaces/paypal";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import {useState} from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { BsPencil, BsTrash } from "react-icons/bs";
|
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
@@ -26,20 +31,36 @@ const columnHelper = createColumnHelper<Package>();
|
|||||||
|
|
||||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||||
|
|
||||||
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
|
const currencyOptions = CURRENCIES.map(({ label, currency }) => ({
|
||||||
|
value: currency,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function PackageCreator({
|
||||||
|
pack,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
pack?: Package;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
const [unit, setUnit] = useState<DurationUnit>(
|
||||||
|
pack?.duration_unit || "months"
|
||||||
|
);
|
||||||
|
|
||||||
const [price, setPrice] = useState(pack?.price || 0);
|
const [price, setPrice] = useState(pack?.price || 0);
|
||||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||||
|
|
||||||
const submit = () => {
|
const submit = useCallback(() => {
|
||||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
(pack ? axios.patch : axios.post)(
|
||||||
|
pack ? `/api/packages/${pack.id}` : "/api/packages",
|
||||||
|
{
|
||||||
duration,
|
duration,
|
||||||
duration_unit: unit,
|
duration_unit: unit,
|
||||||
price,
|
price,
|
||||||
currency,
|
currency,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("New payment has been created successfully!");
|
toast.success("New payment has been created successfully!");
|
||||||
onClose();
|
onClose();
|
||||||
@@ -47,21 +68,35 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong, please try again later!");
|
toast.error("Something went wrong, please try again later!");
|
||||||
});
|
});
|
||||||
|
}, [duration, unit, price, currency, pack, onClose]);
|
||||||
|
|
||||||
|
const currencyDefaultValue = useMemo(() => {
|
||||||
|
return {
|
||||||
|
value: currency || "EUR",
|
||||||
|
label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro",
|
||||||
};
|
};
|
||||||
|
}, [currency]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 py-8">
|
<div className="flex flex-col gap-8 py-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Price *
|
||||||
|
</label>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
<Input
|
||||||
|
defaultValue={price}
|
||||||
|
name="price"
|
||||||
|
type="number"
|
||||||
|
onChange={(e) => setPrice(parseInt(e))}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
options={currencyOptions}
|
||||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
defaultValue={currencyDefaultValue}
|
||||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
value={currencyDefaultValue}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
@@ -76,7 +111,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused
|
||||||
|
? "#D5D9F0"
|
||||||
|
: state.isSelected
|
||||||
|
? "#7872BF"
|
||||||
|
: "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -84,9 +123,16 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Duration *
|
||||||
|
</label>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
|
<Input
|
||||||
|
defaultValue={duration}
|
||||||
|
name="duration"
|
||||||
|
type="number"
|
||||||
|
onChange={(e) => setDuration(parseInt(e))}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={[
|
options={[
|
||||||
@@ -96,7 +142,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
{ value: "years", label: "Years" },
|
{ value: "years", label: "Years" },
|
||||||
]}
|
]}
|
||||||
defaultValue={{ value: "months", label: "Months" }}
|
defaultValue={{ value: "months", label: "Months" }}
|
||||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
onChange={(value) =>
|
||||||
|
setUnit((value?.value as DurationUnit) || "months")
|
||||||
|
}
|
||||||
value={{ value: unit, label: capitalize(unit) }}
|
value={{ value: unit, label: capitalize(unit) }}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
@@ -112,7 +160,11 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused
|
||||||
|
? "#D5D9F0"
|
||||||
|
: state.isSelected
|
||||||
|
? "#7872BF"
|
||||||
|
: "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -120,10 +172,19 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!duration || !price}
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +198,8 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
|
|
||||||
const { packages, reload } = usePackages();
|
const { packages, reload } = usePackages();
|
||||||
|
|
||||||
const deletePackage = async (pack: Package) => {
|
const deletePackage = useCallback(
|
||||||
|
async (pack: Package) => {
|
||||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -157,9 +219,12 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
toast.error("Something went wrong, please try again later.");
|
toast.error("Something went wrong, please try again later.");
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
},
|
||||||
|
[reload]
|
||||||
|
);
|
||||||
|
|
||||||
const defaultColumns = [
|
const defaultColumns = useMemo(
|
||||||
|
() => [
|
||||||
columnHelper.accessor("id", {
|
columnHelper.accessor("id", {
|
||||||
header: "ID",
|
header: "ID",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
@@ -186,13 +251,21 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
cell: ({ row }: { row: { original: Package } }) => {
|
cell: ({ row }: { row: { original: Package } }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{["developer", "admin"].includes(user.type) && (
|
{["developer", "admin"].includes(user?.type) && (
|
||||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
|
<div
|
||||||
|
data-tip="Edit"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => setEditingPackage(row.original)}
|
||||||
|
>
|
||||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{["developer", "admin"].includes(user.type) && (
|
{["developer", "admin"].includes(user?.type) && (
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => deletePackage(row.original)}
|
||||||
|
>
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +273,9 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[deletePackage, user]
|
||||||
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: packages,
|
data: packages,
|
||||||
@@ -208,18 +283,19 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = useCallback(() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setEditingPackage(undefined);
|
setEditingPackage(undefined);
|
||||||
reload();
|
reload();
|
||||||
};
|
}, [reload]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full rounded-xl">
|
<div className="w-full h-full rounded-xl">
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isCreating || !!editingPackage}
|
isOpen={isCreating || !!editingPackage}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
|
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}
|
||||||
|
>
|
||||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||||
@@ -228,7 +304,12 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="p-4 text-left" key={header.id}>
|
<th className="p-4 text-left" key={header.id}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -236,7 +317,10 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<tr
|
||||||
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -248,7 +332,8 @@ export default function PackageList({user}: {user: User}) {
|
|||||||
</table>
|
</table>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
|
||||||
|
>
|
||||||
New Package
|
New Package
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import {averageLevelCalculator} from "@/utils/score";
|
|||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import { createColumnHelper } from "@tanstack/react-table";
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import List from "@/components/List";
|
|
||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
|
|
||||||
type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string};
|
type StudentPerformanceItem = StudentUser & {
|
||||||
|
entitiesLabel: string;
|
||||||
|
group: string;
|
||||||
|
userStats: Stat[];
|
||||||
|
};
|
||||||
|
|
||||||
const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceItem[]; stats: Stat[]}) => {
|
const StudentPerformanceList = ({
|
||||||
|
items = [],
|
||||||
|
}: {
|
||||||
|
items: StudentPerformanceItem[];
|
||||||
|
}) => {
|
||||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||||
@@ -41,46 +48,86 @@ const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceI
|
|||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!isShowingAmount
|
!isShowingAmount
|
||||||
? info.getValue() || 0
|
? info.getValue() || 0
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
: `${
|
||||||
|
Object.keys(
|
||||||
|
groupByExam(
|
||||||
|
info.row.original.userStats.filter(
|
||||||
|
(x) => x.module === "reading"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
} exams`,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("levels.listening", {
|
columnHelper.accessor("levels.listening", {
|
||||||
header: "Listening",
|
header: "Listening",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!isShowingAmount
|
!isShowingAmount
|
||||||
? info.getValue() || 0
|
? info.getValue() || 0
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
: `${
|
||||||
|
Object.keys(
|
||||||
|
groupByExam(
|
||||||
|
info.row.original.userStats.filter(
|
||||||
|
(x) => x.module === "listening"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
} exams`,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("levels.writing", {
|
columnHelper.accessor("levels.writing", {
|
||||||
header: "Writing",
|
header: "Writing",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!isShowingAmount
|
!isShowingAmount
|
||||||
? info.getValue() || 0
|
? info.getValue() || 0
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
: `${
|
||||||
|
Object.keys(
|
||||||
|
groupByExam(
|
||||||
|
info.row.original.userStats.filter(
|
||||||
|
(x) => x.module === "writing"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
} exams`,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("levels.speaking", {
|
columnHelper.accessor("levels.speaking", {
|
||||||
header: "Speaking",
|
header: "Speaking",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!isShowingAmount
|
!isShowingAmount
|
||||||
? info.getValue() || 0
|
? info.getValue() || 0
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
: `${
|
||||||
|
Object.keys(
|
||||||
|
groupByExam(
|
||||||
|
info.row.original.userStats.filter(
|
||||||
|
(x) => x.module === "speaking"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
} exams`,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("levels.level", {
|
columnHelper.accessor("levels.level", {
|
||||||
header: "Level",
|
header: "Level",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!isShowingAmount
|
!isShowingAmount
|
||||||
? info.getValue() || 0
|
? info.getValue() || 0
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
: `${
|
||||||
|
Object.keys(
|
||||||
|
groupByExam(
|
||||||
|
info.row.original.userStats.filter(
|
||||||
|
(x) => x.module === "level"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
} exams`,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("levels", {
|
columnHelper.accessor("userStats", {
|
||||||
id: "overall_level",
|
id: "overall_level",
|
||||||
header: "Overall",
|
header: "Overall",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
!isShowingAmount
|
!isShowingAmount
|
||||||
? averageLevelCalculator(
|
? averageLevelCalculator(
|
||||||
items,
|
info.row.original.focus,
|
||||||
stats.filter((x) => x.user === info.row.original.id),
|
info.getValue()
|
||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
: `${Object.keys(groupByExam(info.getValue())).length} exams`,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -92,17 +139,17 @@ const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceI
|
|||||||
<Table<StudentPerformanceItem>
|
<Table<StudentPerformanceItem>
|
||||||
data={items.sort(
|
data={items.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
averageLevelCalculator(
|
averageLevelCalculator(b.focus, b.userStats) -
|
||||||
items,
|
averageLevelCalculator(a.focus, a.userStats)
|
||||||
stats.filter((x) => x.user === b.id),
|
|
||||||
) -
|
|
||||||
averageLevelCalculator(
|
|
||||||
items,
|
|
||||||
stats.filter((x) => x.user === a.id),
|
|
||||||
),
|
|
||||||
)}
|
)}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]}
|
searchFields={[
|
||||||
|
["name"],
|
||||||
|
["email"],
|
||||||
|
["studentID"],
|
||||||
|
["entitiesLabel"],
|
||||||
|
["group"],
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsTrash } from "react-icons/bs";
|
import {
|
||||||
|
BsCheck,
|
||||||
|
BsCheckCircle,
|
||||||
|
BsFillExclamationOctagonFill,
|
||||||
|
BsTrash,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countries, TCountries } from "countries-list";
|
import { countries, TCountries } from "countries-list";
|
||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
@@ -17,14 +22,11 @@ import useFilterStore from "@/stores/listFilterStore";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { mapBy } from "@/utils";
|
import { mapBy } from "@/utils";
|
||||||
import { exportListToExcel } from "@/utils/users";
|
import { exportListToExcel } from "@/utils/users";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import useUserBalance from "@/hooks/useUserBalance";
|
|
||||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||||
import { WithLabeledEntities } from "@/interfaces/entity";
|
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
import useEntities from "@/hooks/useEntities";
|
import useEntities from "@/hooks/useEntities";
|
||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
import { findAllowedEntities } from "@/utils/permissions";
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
|
const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
|
||||||
const searchFields = [["name"], ["email"], ["entities", ""]];
|
const searchFields = [["name"], ["email"], ["entities", ""]];
|
||||||
@@ -40,31 +42,87 @@ export default function UserList({
|
|||||||
type?: Type;
|
type?: Type;
|
||||||
renderHeader?: (total: number) => JSX.Element;
|
renderHeader?: (total: number) => JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
const [showDemographicInformation, setShowDemographicInformation] =
|
||||||
|
useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
|
||||||
const { users, reload } = useEntitiesUsers(type)
|
const { users, isLoading, reload } = useEntitiesUsers(type);
|
||||||
const { entities } = useEntities()
|
const { entities } = useEntities();
|
||||||
|
|
||||||
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type])
|
const isAdmin = useMemo(
|
||||||
|
() => ["admin", "developer"].includes(user?.type),
|
||||||
|
[user?.type]
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewStudents = useAllowedEntities(user, entities, "view_students")
|
const entitiesViewStudents = useAllowedEntities(
|
||||||
const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students")
|
user,
|
||||||
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students")
|
entities,
|
||||||
|
"view_students"
|
||||||
|
);
|
||||||
|
const entitiesEditStudents = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_students"
|
||||||
|
);
|
||||||
|
const entitiesDeleteStudents = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_students"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewTeachers = useAllowedEntities(user, entities, "view_teachers")
|
const entitiesViewTeachers = useAllowedEntities(
|
||||||
const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers")
|
user,
|
||||||
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers")
|
entities,
|
||||||
|
"view_teachers"
|
||||||
|
);
|
||||||
|
const entitiesEditTeachers = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_teachers"
|
||||||
|
);
|
||||||
|
const entitiesDeleteTeachers = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_teachers"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewCorporates = useAllowedEntities(user, entities, "view_corporates")
|
const entitiesViewCorporates = useAllowedEntities(
|
||||||
const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates")
|
user,
|
||||||
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates")
|
entities,
|
||||||
|
"view_corporates"
|
||||||
|
);
|
||||||
|
const entitiesEditCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_corporates"
|
||||||
|
);
|
||||||
|
const entitiesDeleteCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_corporates"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewMasterCorporates = useAllowedEntities(user, entities, "view_mastercorporates")
|
const entitiesViewMasterCorporates = useAllowedEntities(
|
||||||
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates")
|
user,
|
||||||
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates")
|
entities,
|
||||||
|
"view_mastercorporates"
|
||||||
|
);
|
||||||
|
const entitiesEditMasterCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_mastercorporates"
|
||||||
|
);
|
||||||
|
const entitiesDeleteMasterCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_mastercorporates"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list")
|
const entitiesDownloadUsers = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"download_user_list"
|
||||||
|
);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -73,38 +131,70 @@ export default function UserList({
|
|||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
|
if (today.isAfter(momentDate))
|
||||||
|
return "!text-mti-red-light font-bold line-through";
|
||||||
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
|
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
|
||||||
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
|
if (today.add(2, "weeks").isAfter(momentDate))
|
||||||
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
|
return "!text-mti-rose-light";
|
||||||
|
if (today.add(1, "months").isAfter(momentDate))
|
||||||
|
return "!text-mti-orange-light";
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedUsers = useMemo(() => users.filter((u) => {
|
const allowedUsers = useMemo(
|
||||||
if (isAdmin) return true
|
() =>
|
||||||
if (u.id === user?.id) return false
|
users.filter((u) => {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (u.id === user?.id) return false;
|
||||||
|
|
||||||
switch (u.type) {
|
switch (u.type) {
|
||||||
case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id))
|
case "student":
|
||||||
case "teacher": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewTeachers, 'id').includes(id))
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
case 'corporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewCorporates, 'id').includes(id))
|
mapBy(entitiesViewStudents, "id").includes(id)
|
||||||
case 'mastercorporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewMasterCorporates, 'id').includes(id))
|
);
|
||||||
default: return false
|
case "teacher":
|
||||||
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
|
mapBy(entitiesViewTeachers, "id").includes(id)
|
||||||
|
);
|
||||||
|
case "corporate":
|
||||||
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
|
mapBy(entitiesViewCorporates, "id").includes(id)
|
||||||
|
);
|
||||||
|
case "mastercorporate":
|
||||||
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
|
mapBy(entitiesViewMasterCorporates, "id").includes(id)
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
, [entitiesViewCorporates, entitiesViewMasterCorporates, entitiesViewStudents, entitiesViewTeachers, isAdmin, user?.id, users])
|
[
|
||||||
|
entitiesViewCorporates,
|
||||||
|
entitiesViewMasterCorporates,
|
||||||
|
entitiesViewStudents,
|
||||||
|
entitiesViewTeachers,
|
||||||
|
isAdmin,
|
||||||
|
user?.id,
|
||||||
|
users,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const displayUsers = useMemo(() =>
|
const displayUsers = useMemo(
|
||||||
filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers,
|
() =>
|
||||||
[filters, allowedUsers])
|
filters.length > 0
|
||||||
|
? filters.reduce((d, f) => d.filter(f), allowedUsers)
|
||||||
|
: allowedUsers,
|
||||||
|
[filters, allowedUsers]
|
||||||
|
);
|
||||||
|
|
||||||
const deleteAccount = (user: User) => {
|
const deleteAccount = (user: User) => {
|
||||||
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`))
|
||||||
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
|
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("User deleted successfully!");
|
toast.success("User deleted successfully!");
|
||||||
reload()
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", { toastId: "delete-error" });
|
toast.error("Something went wrong!", { toastId: "delete-error" });
|
||||||
@@ -130,8 +220,11 @@ export default function UserList({
|
|||||||
const toggleDisableAccount = (user: User) => {
|
const toggleDisableAccount = (user: User) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name
|
`Are you sure you want to ${
|
||||||
}'s account? This change is usually related to their payment state.`,
|
user.status === "disabled" ? "enable" : "disable"
|
||||||
|
} ${
|
||||||
|
user.name
|
||||||
|
}'s account? This change is usually related to their payment state.`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +235,11 @@ export default function UserList({
|
|||||||
status: user.status === "disabled" ? "active" : "disabled",
|
status: user.status === "disabled" ? "active" : "disabled",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`);
|
toast.success(
|
||||||
|
`User ${
|
||||||
|
user.status === "disabled" ? "enabled" : "disabled"
|
||||||
|
} successfully!`
|
||||||
|
);
|
||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -151,45 +248,60 @@ export default function UserList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getEditPermission = (type: Type) => {
|
const getEditPermission = (type: Type) => {
|
||||||
if (type === "student") return entitiesEditStudents
|
if (type === "student") return entitiesEditStudents;
|
||||||
if (type === "teacher") return entitiesEditTeachers
|
if (type === "teacher") return entitiesEditTeachers;
|
||||||
if (type === "corporate") return entitiesEditCorporates
|
if (type === "corporate") return entitiesEditCorporates;
|
||||||
if (type === "mastercorporate") return entitiesEditMasterCorporates
|
if (type === "mastercorporate") return entitiesEditMasterCorporates;
|
||||||
|
|
||||||
return []
|
return [];
|
||||||
}
|
};
|
||||||
|
|
||||||
const getDeletePermission = (type: Type) => {
|
const getDeletePermission = (type: Type) => {
|
||||||
if (type === "student") return entitiesDeleteStudents
|
if (type === "student") return entitiesDeleteStudents;
|
||||||
if (type === "teacher") return entitiesDeleteTeachers
|
if (type === "teacher") return entitiesDeleteTeachers;
|
||||||
if (type === "corporate") return entitiesDeleteCorporates
|
if (type === "corporate") return entitiesDeleteCorporates;
|
||||||
if (type === "mastercorporate") return entitiesDeleteMasterCorporates
|
if (type === "mastercorporate") return entitiesDeleteMasterCorporates;
|
||||||
|
|
||||||
return []
|
return [];
|
||||||
}
|
};
|
||||||
|
|
||||||
const canEditUser = (u: User) =>
|
const canEditUser = (u: User) =>
|
||||||
isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id))
|
isAdmin ||
|
||||||
|
u.entities.some((e) =>
|
||||||
|
mapBy(getEditPermission(u.type), "id").includes(e.id)
|
||||||
|
);
|
||||||
|
|
||||||
const canDeleteUser = (u: User) =>
|
const canDeleteUser = (u: User) =>
|
||||||
isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id))
|
isAdmin ||
|
||||||
|
u.entities.some((e) =>
|
||||||
|
mapBy(getDeletePermission(u.type), "id").includes(e.id)
|
||||||
|
);
|
||||||
|
|
||||||
const actionColumn = ({ row }: { row: { original: User } }) => {
|
const actionColumn = ({ row }: { row: { original: User } }) => {
|
||||||
const canEdit = canEditUser(row.original)
|
const canEdit = canEditUser(row.original);
|
||||||
const canDelete = canDeleteUser(row.original)
|
const canDelete = canDeleteUser(row.original);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{!row.original.isVerified && canEdit && (
|
{!row.original.isVerified && canEdit && (
|
||||||
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
|
<div
|
||||||
|
data-tip="Verify User"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => verifyAccount(row.original)}
|
||||||
|
>
|
||||||
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div
|
<div
|
||||||
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"}
|
data-tip={
|
||||||
|
row.original.status === "disabled"
|
||||||
|
? "Enable User"
|
||||||
|
: "Disable User"
|
||||||
|
}
|
||||||
className="cursor-pointer tooltip"
|
className="cursor-pointer tooltip"
|
||||||
onClick={() => toggleDisableAccount(row.original)}>
|
onClick={() => toggleDisableAccount(row.original)}
|
||||||
|
>
|
||||||
{row.original.status === "disabled" ? (
|
{row.original.status === "disabled" ? (
|
||||||
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
) : (
|
) : (
|
||||||
@@ -198,7 +310,11 @@ export default function UserList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}>
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => deleteAccount(row.original)}
|
||||||
|
>
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -213,11 +329,12 @@ export default function UserList({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
canEditUser(row.original) &&
|
canEditUser(row.original) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
{getValue()}
|
{getValue()}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -226,8 +343,14 @@ export default function UserList({
|
|||||||
header: "Country",
|
header: "Country",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
info.getValue()
|
info.getValue()
|
||||||
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name
|
? `${
|
||||||
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
|
countryCodes.findOne("countryCode" as any, info.getValue())?.flag
|
||||||
|
} ${
|
||||||
|
countries[info.getValue() as unknown as keyof TCountries]?.name
|
||||||
|
} (+${
|
||||||
|
countryCodes.findOne("countryCode" as any, info.getValue())
|
||||||
|
?.countryCallingCode
|
||||||
|
})`
|
||||||
: "N/A",
|
: "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("demographicInformation.phone", {
|
columnHelper.accessor("demographicInformation.phone", {
|
||||||
@@ -237,17 +360,25 @@ export default function UserList({
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor(
|
columnHelper.accessor(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
|
x.type === "corporate" || x.type === "mastercorporate"
|
||||||
|
? x.demographicInformation?.position
|
||||||
|
: x.demographicInformation?.employment,
|
||||||
{
|
{
|
||||||
id: "employment",
|
id: "employment",
|
||||||
header: "Employment",
|
header: "Employment",
|
||||||
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
|
cell: (info) =>
|
||||||
|
(info.row.original.type === "corporate"
|
||||||
|
? info.getValue()
|
||||||
|
: capitalize(info.getValue())) || "N/A",
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
}
|
||||||
),
|
),
|
||||||
columnHelper.accessor("lastLogin", {
|
columnHelper.accessor("lastLogin", {
|
||||||
header: "Last Login",
|
header: "Last Login",
|
||||||
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
|
cell: (info) =>
|
||||||
|
!!info.getValue()
|
||||||
|
? moment(info.getValue()).format("YYYY-MM-DD HH:mm")
|
||||||
|
: "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("demographicInformation.gender", {
|
columnHelper.accessor("demographicInformation.gender", {
|
||||||
header: "Gender",
|
header: "Gender",
|
||||||
@@ -256,13 +387,16 @@ export default function UserList({
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
header: (
|
header: (
|
||||||
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setShowDemographicInformation((prev) => !prev)}
|
||||||
|
>
|
||||||
Switch
|
Switch
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: actionColumn,
|
cell: actionColumn,
|
||||||
sortable: false
|
sortable: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -273,11 +407,12 @@ export default function UserList({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
canEditUser(row.original) &&
|
canEditUser(row.original) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
{getValue()}
|
{getValue()}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -288,9 +423,12 @@ export default function UserList({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
canEditUser(row.original) &&
|
canEditUser(row.original) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={() => (canEditUser(row.original) ? setSelectedUser(row.original) : null)}>
|
onClick={() =>
|
||||||
|
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
{getValue()}
|
{getValue()}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -305,13 +443,21 @@ export default function UserList({
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("entities", {
|
columnHelper.accessor("entities", {
|
||||||
header: "Entities",
|
header: "Entities",
|
||||||
cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '),
|
cell: ({ getValue }) => mapBy(getValue(), "label").join(", "),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("subscriptionExpirationDate", {
|
columnHelper.accessor("subscriptionExpirationDate", {
|
||||||
header: "Expiration",
|
header: "Expiration",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
|
<span
|
||||||
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
|
className={clsx(
|
||||||
|
info.getValue()
|
||||||
|
? expirationDateColor(moment(info.getValue()).toDate())
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!info.getValue()
|
||||||
|
? "No expiry date"
|
||||||
|
: moment(info.getValue()).format("DD/MM/YYYY")}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -323,8 +469,9 @@ export default function UserList({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
info.getValue() && "!bg-mti-purple-light ",
|
info.getValue() && "!bg-mti-purple-light "
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<BsCheck color="white" className="w-full h-full" />
|
<BsCheck color="white" className="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,26 +479,32 @@ export default function UserList({
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
header: (
|
header: (
|
||||||
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setShowDemographicInformation((prev) => !prev)}
|
||||||
|
>
|
||||||
Switch
|
Switch
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: actionColumn,
|
cell: actionColumn,
|
||||||
sortable: false
|
sortable: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
|
const downloadExcel = async (rows: WithLabeledEntities<User>[]) => {
|
||||||
if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.")
|
if (entitiesDownloadUsers.length === 0)
|
||||||
|
return toast.error("You are not allowed to download the user list.");
|
||||||
|
|
||||||
const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e)))
|
const allowedRows = rows;
|
||||||
const csv = exportListToExcel(allowedRows);
|
const csv = await exportListToExcel(allowedRows);
|
||||||
|
|
||||||
const element = document.createElement("a");
|
const element = document.createElement("a");
|
||||||
const file = new Blob([csv], { type: "text/csv" });
|
const file = new Blob([csv], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
element.href = URL.createObjectURL(file);
|
element.href = URL.createObjectURL(file);
|
||||||
element.download = "users.csv";
|
element.download = "users.xlsx";
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
element.click();
|
element.click();
|
||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
@@ -359,10 +512,15 @@ export default function UserList({
|
|||||||
|
|
||||||
const viewStudentFilter = (x: User) => x.type === "student";
|
const viewStudentFilter = (x: User) => x.type === "student";
|
||||||
const viewTeacherFilter = (x: User) => x.type === "teacher";
|
const viewTeacherFilter = (x: User) => x.type === "teacher";
|
||||||
const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id));
|
const belongsToAdminFilter = (x: User) =>
|
||||||
|
x.entities.some(({ id }) =>
|
||||||
|
mapBy(selectedUser?.entities || [], "id").includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x);
|
const viewStudentFilterBelongsToAdmin = (x: User) =>
|
||||||
const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x);
|
viewStudentFilter(x) && belongsToAdminFilter(x);
|
||||||
|
const viewTeacherFilterBelongsToAdmin = (x: User) =>
|
||||||
|
viewTeacherFilter(x) && belongsToAdminFilter(x);
|
||||||
|
|
||||||
const renderUserCard = (selectedUser: User) => {
|
const renderUserCard = (selectedUser: User) => {
|
||||||
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
|
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
|
||||||
@@ -373,7 +531,9 @@ export default function UserList({
|
|||||||
maxUserAmount={0}
|
maxUserAmount={0}
|
||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
|
(selectedUser.type === "corporate" ||
|
||||||
|
selectedUser.type === "teacher") &&
|
||||||
|
studentsFromAdmin.length > 0
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-students",
|
id: "view-students",
|
||||||
@@ -389,7 +549,9 @@ export default function UserList({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={
|
onViewTeachers={
|
||||||
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
|
(selectedUser.type === "corporate" ||
|
||||||
|
selectedUser.type === "student") &&
|
||||||
|
teachersFromAdmin.length > 0
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-teachers",
|
id: "view-teachers",
|
||||||
@@ -413,7 +575,7 @@ export default function UserList({
|
|||||||
});
|
});
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: belongsToAdminFilter
|
filter: belongsToAdminFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/users");
|
router.push("/users");
|
||||||
@@ -434,14 +596,24 @@ export default function UserList({
|
|||||||
<>
|
<>
|
||||||
{renderHeader && renderHeader(displayUsers.length)}
|
{renderHeader && renderHeader(displayUsers.length)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
<Modal
|
||||||
|
isOpen={!!selectedUser}
|
||||||
|
onClose={() => setSelectedUser(undefined)}
|
||||||
|
>
|
||||||
{selectedUser && renderUserCard(selectedUser)}
|
{selectedUser && renderUserCard(selectedUser)}
|
||||||
</Modal>
|
</Modal>
|
||||||
<Table<WithLabeledEntities<User>>
|
<Table<WithLabeledEntities<User>>
|
||||||
data={displayUsers}
|
data={displayUsers}
|
||||||
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
|
columns={
|
||||||
|
(!showDemographicInformation
|
||||||
|
? defaultColumns
|
||||||
|
: demographicColumns) as any
|
||||||
|
}
|
||||||
searchFields={searchFields}
|
searchFields={searchFields}
|
||||||
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined}
|
onDownload={
|
||||||
|
entitiesDownloadUsers.length > 0 ? downloadExcel : undefined
|
||||||
|
}
|
||||||
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import clsx from "clsx";
|
|||||||
import CodeList from "./CodeList";
|
import CodeList from "./CodeList";
|
||||||
import DiscountList from "./DiscountList";
|
import DiscountList from "./DiscountList";
|
||||||
import ExamList from "./ExamList";
|
import ExamList from "./ExamList";
|
||||||
import GroupList from "./GroupList";
|
|
||||||
import PackageList from "./PackageList";
|
import PackageList from "./PackageList";
|
||||||
import UserList from "./UserList";
|
import UserList from "./UserList";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { CorporateUser, TeacherUser, Type, User } from "@/interfaces/user";
|
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize, uniqBy } from "lodash";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import CountrySelect from "@/components/Low/CountrySelect";
|
import CountrySelect from "@/components/Low/CountrySelect";
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import { getUserName } from "@/utils/users";
|
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
|
||||||
@@ -48,23 +41,44 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserCreator({ user, users, entities = [], permissions, onFinish }: Props) {
|
export default function UserCreator({
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
entities = [],
|
||||||
|
permissions,
|
||||||
|
onFinish,
|
||||||
|
}: Props) {
|
||||||
const [name, setName] = useState<string>();
|
const [name, setName] = useState<string>();
|
||||||
const [email, setEmail] = useState<string>();
|
const [email, setEmail] = useState<string>();
|
||||||
const [phone, setPhone] = useState<string>();
|
const [phone, setPhone] = useState<string>();
|
||||||
@@ -75,13 +89,15 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
const [password, setPassword] = useState<string>();
|
const [password, setPassword] = useState<string>();
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>();
|
const [confirmPassword, setConfirmPassword] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user?.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [position, setPosition] = useState<string>();
|
const [position, setPosition] = useState<string>();
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
|
||||||
|
|
||||||
const { groups } = useEntitiesGroups();
|
const { groups } = useEntitiesGroups();
|
||||||
|
|
||||||
@@ -90,11 +106,16 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const createUser = () => {
|
const createUser = () => {
|
||||||
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
|
if (!name || name.trim().length === 0)
|
||||||
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
|
return toast.error("Please enter a valid name!");
|
||||||
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
|
if (!email || email.trim().length === 0)
|
||||||
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
|
return toast.error("Please enter a valid e-mail address!");
|
||||||
if (password !== confirmPassword) return toast.error("The passwords do not match!");
|
if (users.map((x) => x.email).includes(email.trim()))
|
||||||
|
return toast.error("That e-mail is already in use!");
|
||||||
|
if (!password || password.trim().length < 6)
|
||||||
|
return toast.error("Please enter a valid password!");
|
||||||
|
if (password !== confirmPassword)
|
||||||
|
return toast.error("The passwords do not match!");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -128,8 +149,12 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
setStudentID("");
|
setStudentID("");
|
||||||
setCountry(user?.demographicInformation?.country);
|
setCountry(user?.demographicInformation?.country);
|
||||||
setGroup(null);
|
setGroup(null);
|
||||||
setEntity((entities || [])[0]?.id || undefined)
|
setEntity((entities || [])[0]?.id || undefined);
|
||||||
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
|
setExpiryDate(
|
||||||
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user?.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
|
);
|
||||||
setIsExpiryDateEnabled(true);
|
setIsExpiryDateEnabled(true);
|
||||||
setType("student");
|
setType("student");
|
||||||
setPosition(undefined);
|
setPosition(undefined);
|
||||||
@@ -145,10 +170,34 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
|
<Input
|
||||||
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
|
required
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="E-mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
@@ -160,11 +209,21 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Country *
|
||||||
|
</label>
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
label="Phone number"
|
||||||
|
value={phone}
|
||||||
|
onChange={setPhone}
|
||||||
|
placeholder="Phone number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
{type === "student" && (
|
{type === "student" && (
|
||||||
<>
|
<>
|
||||||
@@ -177,14 +236,26 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
placeholder="National ID or Passport number"
|
placeholder="National ID or Passport number"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="studentID"
|
||||||
|
label="Student ID"
|
||||||
|
onChange={setStudentID}
|
||||||
|
value={studentID}
|
||||||
|
placeholder="Student ID"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Entity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
defaultValue={{
|
||||||
|
value: (entities || [])[0]?.id,
|
||||||
|
label: (entities || [])[0]?.label,
|
||||||
|
}}
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
@@ -192,11 +263,20 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{["corporate", "mastercorporate"].includes(type) && (
|
{["corporate", "mastercorporate"].includes(type) && (
|
||||||
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
onChange={setPosition}
|
||||||
|
value={position}
|
||||||
|
placeholder="Department"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Classroom
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
options={groups
|
options={groups
|
||||||
.filter((x) => x.entity?.id === entity)
|
.filter((x) => x.entity?.id === entity)
|
||||||
@@ -209,38 +289,52 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col gap-4",
|
"flex flex-col gap-4",
|
||||||
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
|
!checkAccess(user, [
|
||||||
)}>
|
"developer",
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && "col-span-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
{user && (
|
{user && (
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as Type)}
|
onChange={(e) => setType(e.target.value as Type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
>
|
||||||
.filter((x) => {
|
{Object.keys(USER_TYPE_LABELS).reduce<string[]>((acc, x) => {
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
if (checkAccess(user, getTypesOfUser(list), permissions, perm))
|
||||||
})
|
acc.push(x);
|
||||||
.map((type) => (
|
return acc;
|
||||||
<option key={type} value={type}>
|
}, [])}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
{user &&
|
||||||
|
checkAccess(user, [
|
||||||
|
"developer",
|
||||||
|
"admin",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Expiry Date
|
||||||
|
</label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={isExpiryDateEnabled}
|
isChecked={isExpiryDateEnabled}
|
||||||
onChange={setIsExpiryDateEnabled}
|
onChange={setIsExpiryDateEnabled}
|
||||||
disabled={!!user?.subscriptionExpirationDate}>
|
disabled={!!user?.subscriptionExpirationDate}
|
||||||
|
>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,11 +343,15 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
filterDate={(date) =>
|
filterDate={(date) =>
|
||||||
moment(date).isAfter(new Date()) &&
|
moment(date).isAfter(new Date()) &&
|
||||||
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
|
(user?.subscriptionExpirationDate
|
||||||
|
? moment(date).isBefore(
|
||||||
|
user?.subscriptionExpirationDate
|
||||||
|
)
|
||||||
|
: true)
|
||||||
}
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={expiryDate}
|
selected={expiryDate}
|
||||||
@@ -265,7 +363,11 @@ export default function UserCreator({ user, users, entities = [], permissions, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
|
<Button
|
||||||
|
onClick={createUser}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}
|
||||||
|
>
|
||||||
Create User
|
Create User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,10 +140,10 @@ export default function ExamPage({
|
|||||||
|
|
||||||
useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
|
useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
|
||||||
|
|
||||||
useEffect(() => {
|
/* useEffect(() => {
|
||||||
setModuleLock(true);
|
setModuleLock(true);
|
||||||
}, [flags.finalizeModule]);
|
}, [flags.finalizeModule]);
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (flags.finalizeModule && !showSolutions) {
|
if (flags.finalizeModule && !showSolutions) {
|
||||||
if (
|
if (
|
||||||
@@ -183,9 +183,9 @@ export default function ExamPage({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
const updatedSolutions = userSolutions.map((solution) => {
|
const updatedSolutions = userSolutions.map((solution) => {
|
||||||
const completed = results
|
const completed = results.find(
|
||||||
.filter((r) => r !== null)
|
(c: any) => c && c.exercise === solution.exercise
|
||||||
.find((c: any) => c.exercise === solution.exercise);
|
);
|
||||||
return completed || solution;
|
return completed || solution;
|
||||||
});
|
});
|
||||||
setUserSolutions(updatedSolutions);
|
setUserSolutions(updatedSolutions);
|
||||||
@@ -279,7 +279,7 @@ export default function ExamPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
userSolutions.forEach((x) => {
|
userSolutions.forEach((x) => {
|
||||||
if (x.isPractice === isPractice) {
|
if (isPractice ? x.isPractice : !x.isPractice) {
|
||||||
const examModule =
|
const examModule =
|
||||||
x.module ||
|
x.module ||
|
||||||
(x.type === "writing"
|
(x.type === "writing"
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ export default function RegisterCorporate({
|
|||||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||||
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
const { acceptedTerms, renderCheckbox } = useAcceptedTerms();
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers({ type: "agent" });
|
||||||
|
|
||||||
const onSuccess = () =>
|
const onSuccess = () =>
|
||||||
toast.success(
|
toast.success(
|
||||||
"An e-mail has been sent, please make sure to check your spam folder!",
|
"An e-mail has been sent, please make sure to check your spam folder!"
|
||||||
);
|
);
|
||||||
|
|
||||||
const onError = (e: Error) => {
|
const onError = (e: Error) => {
|
||||||
@@ -83,7 +83,7 @@ export default function RegisterCorporate({
|
|||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
mutateUser(response.data.user).then(() =>
|
mutateUser(response.data.user).then(() =>
|
||||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
sendEmailVerification(setIsLoading, onSuccess, onError)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -178,9 +178,10 @@ export default function RegisterCorporate({
|
|||||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "No referral" },
|
{ value: "", label: "No referral" },
|
||||||
...users
|
...users.map((x) => ({
|
||||||
.filter((u) => u.type === "agent")
|
value: x.id,
|
||||||
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
|
label: `${x.name} - ${x.email}`,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
defaultValue={{ value: "", label: "No referral" }}
|
defaultValue={{ value: "", label: "No referral" }}
|
||||||
onChange={(value) => setReferralAgent(value?.value)}
|
onChange={(value) => setReferralAgent(value?.value)}
|
||||||
@@ -229,7 +230,7 @@ export default function RegisterCorporate({
|
|||||||
? availableDurations[
|
? availableDurations[
|
||||||
value.value as keyof typeof availableDurations
|
value.value as keyof typeof availableDurations
|
||||||
].number
|
].number
|
||||||
: 1,
|
: 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import useExamStore from "@/stores/exam";
|
|||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import Layout from "../components/High/Layout";
|
import Layout from "../components/High/Layout";
|
||||||
import useEntities from "../hooks/useEntities";
|
import useEntities from "../hooks/useEntities";
|
||||||
import UserProfileSkeleton from "../components/Medium/UserProfileSkeleton";
|
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -61,7 +59,14 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
|
|
||||||
return pageProps?.user ? (
|
return pageProps?.user ? (
|
||||||
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
|
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
|
||||||
{loading ? <UserProfileSkeleton /> : <Component {...pageProps} />}
|
{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>
|
</Layout>
|
||||||
) : (
|
) : (
|
||||||
<Component {...pageProps} />
|
<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 client from "@/lib/mongodb";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { Code, Group, Type } from "@/interfaces/user";
|
import { Code, } from "@/interfaces/user";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
|
||||||
import { isAdmin } from "@/utils/users";
|
|
||||||
import { requestUser } from "@/utils/api";
|
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);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
@@ -30,7 +22,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const { entities } = req.query as { entities?: string[] };
|
const { entities } = req.query as { entities?: string[] };
|
||||||
if (entities)
|
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());
|
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 { entity } = req.query as { entity?: string };
|
||||||
|
|
||||||
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
|
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
|
||||||
|
|
||||||
res.status(200).json(snapshot);
|
res.status(200).json(snapshot);
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import 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 { Module } from "@/interfaces";
|
||||||
import { getUserCorporate } from "@/utils/groups.be";
|
import { Exam, ExamBase, InstructorGender, LevelExam, ListeningExam, ReadingExam, SpeakingExam, Variant } from "@/interfaces/exam";
|
||||||
import { requestUser } from "@/utils/api";
|
import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
|
||||||
import { isAdmin } from "@/utils/users";
|
import client from "@/lib/mongodb";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { mapBy } from "@/utils";
|
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);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
// Temporary: Adding UUID here but later move to backend.
|
||||||
|
function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
|
||||||
|
const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
|
||||||
|
|
||||||
|
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) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return await GET(req, res);
|
if (req.method === "GET") return await GET(req, res);
|
||||||
if (req.method === "POST") return await POST(req, res);
|
if (req.method === "POST") return await POST(req, res);
|
||||||
@@ -40,16 +62,17 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return res.status(401).json({ ok: false });
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
const { module } = req.query as { module: string };
|
const { module } = req.query as { module: string };
|
||||||
|
|
||||||
const session = client.startSession();
|
const session = client.startSession();
|
||||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
|
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exam = {
|
let exam = {
|
||||||
|
access: "public", // default access is public
|
||||||
...req.body,
|
...req.body,
|
||||||
module: module,
|
module: module,
|
||||||
entities,
|
entities,
|
||||||
@@ -57,29 +80,96 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Temporary: Adding UUID here but later move to backend.
|
||||||
|
exam = addUUIDs(exam);
|
||||||
|
|
||||||
|
let responseStatus: number;
|
||||||
|
let responseMessage: string;
|
||||||
|
|
||||||
await session.withTransaction(async () => {
|
await session.withTransaction(async () => {
|
||||||
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
||||||
|
|
||||||
// Check whether the id of the exam matches another exam with different
|
// Check whether the id of the exam matches another exam with different
|
||||||
// owners, throw exception if there is, else allow editing
|
// owners, throw exception if there is, else allow editing
|
||||||
const ownersSet = new Set(docSnap?.owners || []);
|
const existingExamOwners = docSnap?.owners ?? [];
|
||||||
|
const newExamOwners = exam.owners ?? [];
|
||||||
|
|
||||||
if (docSnap !== null && docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) {
|
const ownersSet = new Set(existingExamOwners);
|
||||||
|
|
||||||
|
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
|
||||||
throw new Error("Name already exists");
|
throw new Error("Name already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exam.requiresApproval === true) {
|
||||||
|
exam.access = "confidential";
|
||||||
|
}
|
||||||
|
|
||||||
await db.collection(module).updateOne(
|
await db.collection(module).updateOne(
|
||||||
{ id: req.body.id },
|
{ id: req.body.id },
|
||||||
{ $set: { id: req.body.id, ...exam } },
|
{ $set: { id: req.body.id, ...exam } },
|
||||||
{
|
{
|
||||||
upsert: true,
|
upsert: true,
|
||||||
session
|
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(200).json(exam);
|
res.status(responseStatus).json({
|
||||||
|
message: responseMessage,
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Transaction failed: ", error);
|
console.error("Transaction failed: ", error);
|
||||||
res.status(500).json({ ok: false, error: (error as any).message });
|
res.status(500).json({ ok: false, error: (error as any).message });
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import client from "@/lib/mongodb";
|
|||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { flatten } from "lodash";
|
import { flatten } from "lodash";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import { AccessType, Exam } from "@/interfaces/exam";
|
||||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
|
import { requestUser } from "../../../utils/api";
|
||||||
|
import { mapBy } from "../../../utils";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -22,9 +24,29 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ ok: false });
|
||||||
return;
|
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 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) => ({
|
return snapshot.map((doc) => ({
|
||||||
...doc,
|
...doc,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ ok: false });
|
||||||
return;
|
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);
|
res.status(200).json(docs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('response', response.data);
|
||||||
res.status(response.status).json(response.data);
|
res.status(response.status).json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
res.status(500).json({ message: 'An unexpected error occurred' });
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,15 @@ import clsx from "clsx";
|
|||||||
import { capitalize, uniqBy } from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { BsBook, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsBuilding,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { futureAssignmentFilter } from "@/utils/assignments";
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
@@ -31,69 +39,140 @@ import { requestUser } from "@/utils/api";
|
|||||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||||
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
const user = await requestUser(req, res)
|
async ({ req, res, params }) => {
|
||||||
if (!user) return redirect("/login")
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
|
if (
|
||||||
return redirect("/assignments")
|
!checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"corporate",
|
||||||
|
"teacher",
|
||||||
|
"mastercorporate",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return redirect("/assignments");
|
||||||
|
|
||||||
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
|
res.setHeader(
|
||||||
|
"Cache-Control",
|
||||||
|
"public, s-maxage=10, stale-while-revalidate=59"
|
||||||
|
);
|
||||||
|
|
||||||
const { id } = params as { id: string };
|
const { id } = params as { id: string };
|
||||||
|
|
||||||
const assignment = await getAssignment(id);
|
const assignment = await getAssignment(id);
|
||||||
if (!assignment) return redirect("/assignments")
|
if (!assignment) return redirect("/assignments");
|
||||||
|
|
||||||
const entity = await getEntityWithRoles(assignment.entity || "")
|
const entity = await getEntityWithRoles(assignment.entity || "");
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
const users = await getUsers()
|
const users = await getUsers(
|
||||||
|
{},
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
return { props: serialize({ user, users, assignment }) };
|
return { props: serialize({ user, users, assignment }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments")
|
if (!doesEntityAllow(user, entity, "view_assignments"))
|
||||||
|
return redirect("/assignments");
|
||||||
|
|
||||||
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id));
|
const [users, gradingSystem] = await Promise.all([
|
||||||
const gradingSystem = await getGradingSystemByEntity(entity.id)
|
await (checkAccess(user, ["developer", "admin"])
|
||||||
|
? getUsers(
|
||||||
|
{},
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: getEntityUsers(
|
||||||
|
entity.id,
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
getGradingSystemByEntity(entity.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return { props: serialize({ user, users, entity, assignment, gradingSystem }) };
|
return {
|
||||||
}, sessionOptions);
|
props: serialize({ user, users, entity, assignment, gradingSystem }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sessionOptions
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
assignment: Assignment;
|
assignment: Assignment;
|
||||||
entity?: EntityWithRoles
|
entity?: EntityWithRoles;
|
||||||
gradingSystem?: Grading
|
gradingSystem?: Grading;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({ user, users, entity, assignment, gradingSystem }: Props) {
|
export default function AssignmentView({
|
||||||
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment')
|
user,
|
||||||
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment')
|
users,
|
||||||
|
entity,
|
||||||
|
assignment,
|
||||||
|
gradingSystem,
|
||||||
|
}: Props) {
|
||||||
|
const canDeleteAssignment = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"delete_assignment"
|
||||||
|
);
|
||||||
|
const canStartAssignment = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"start_assignment"
|
||||||
|
);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const dispatch = useExamStore((state) => state.dispatch);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
|
|
||||||
const deleteAssignment = async () => {
|
const deleteAssignment = async () => {
|
||||||
if (!canDeleteAssignment) return
|
if (!canDeleteAssignment) return;
|
||||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/assignments/${assignment?.id}`)
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully deleted the assignment "${assignment?.name}".`
|
||||||
|
)
|
||||||
|
)
|
||||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
.finally(() => router.push("/assignments"));
|
.finally(() => router.push("/assignments"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const startAssignment = () => {
|
const startAssignment = () => {
|
||||||
if (!canStartAssignment) return
|
if (!canStartAssignment) return;
|
||||||
if (!confirm("Are you sure you want to start this assignment?")) return;
|
if (!confirm("Are you sure you want to start this assignment?")) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/assignments/${assignment.id}/start`)
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${assignment.name}" has been started successfully!`);
|
toast.success(
|
||||||
|
`The assignment "${assignment.name}" has been started successfully!`
|
||||||
|
);
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -115,15 +194,26 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
return resultModuleBandScores.length === 0
|
||||||
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
assignment.results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
|
const aggregateScoresByModule = (
|
||||||
|
stats: Stat[]
|
||||||
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: { total: number; missing: number; correct: number };
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
} = {
|
} = {
|
||||||
@@ -154,7 +244,9 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
stats.filter(x => !x.isPractice).forEach((x) => {
|
stats
|
||||||
|
.filter((x) => !x.isPractice)
|
||||||
|
.forEach((x) => {
|
||||||
scores[x.module!] = {
|
scores[x.module!] = {
|
||||||
total: scores[x.module!].total + x.score.total,
|
total: scores[x.module!].total + x.score.total,
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
@@ -167,28 +259,53 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const levelAverage = (aggregatedLevels: { module: Module, level: number }[]) =>
|
const levelAverage = (
|
||||||
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
aggregatedLevels: { module: Module; level: number }[]
|
||||||
|
) =>
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0
|
||||||
|
) / aggregatedLevels.length;
|
||||||
|
|
||||||
const renderLevelScore = (stats: Stat[], aggregatedLevels: { module: Module, level: number }[]) => {
|
const renderLevelScore = (
|
||||||
const defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1)
|
stats: Stat[],
|
||||||
if (!stats.every(s => s.module === "level")) return defaultLevelScore
|
aggregatedLevels: { module: Module; level: number }[]
|
||||||
if (!gradingSystem) return defaultLevelScore
|
) => {
|
||||||
|
const defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1);
|
||||||
|
if (!stats.every((s) => s.module === "level")) return defaultLevelScore;
|
||||||
|
if (!gradingSystem) return defaultLevelScore;
|
||||||
|
|
||||||
const score = {
|
const score = {
|
||||||
correct: stats.reduce((acc, curr) => acc + curr.score.correct, 0),
|
correct: stats.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
total: stats.reduce((acc, curr) => acc + curr.score.total, 0)
|
total: stats.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
}
|
};
|
||||||
|
|
||||||
const level: number = calculateBandScore(score.correct, score.total, "level", user.focus);
|
const level: number = calculateBandScore(
|
||||||
|
score.correct,
|
||||||
|
score.total,
|
||||||
|
"level",
|
||||||
|
user.focus
|
||||||
|
);
|
||||||
|
|
||||||
return getGradingLabel(level, gradingSystem.steps)
|
return getGradingLabel(level, gradingSystem.steps);
|
||||||
}
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
stats: Stat[],
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
user: string,
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
focus: "academic" | "general"
|
||||||
|
) => {
|
||||||
|
const correct = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||||
|
(x) => x.total > 0
|
||||||
|
);
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
module: x.module,
|
module: x.module,
|
||||||
@@ -198,19 +315,22 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
const timeSpent = stats[0].timeSpent;
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
|
getExamById(stat.module, stat.exam)
|
||||||
|
);
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'INIT_SOLUTIONS', payload: {
|
type: "INIT_SOLUTIONS",
|
||||||
|
payload: {
|
||||||
exams: exams.map((x) => x!).sort(sortByModule),
|
exams: exams.map((x) => x!).sort(sortByModule),
|
||||||
modules: exams
|
modules: exams
|
||||||
.map((x) => x!)
|
.map((x) => x!)
|
||||||
.sort(sortByModule)
|
.sort(sortByModule)
|
||||||
.map((x) => x!.module),
|
.map((x) => x!.module),
|
||||||
stats,
|
stats,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
@@ -221,11 +341,15 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
<>
|
<>
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
<span className="font-medium">
|
||||||
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
|
</span>
|
||||||
{timeSpent && (
|
{timeSpent && (
|
||||||
<>
|
<>
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
<span className="text-sm">
|
||||||
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -233,10 +357,10 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
correct / total < 0.3 && "text-mti-rose"
|
||||||
)}>
|
)}
|
||||||
Level{' '}
|
>
|
||||||
{renderLevelScore(stats, aggregatedLevels)}
|
Level {renderLevelScore(stats, aggregatedLevels)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -251,8 +375,9 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
@@ -279,11 +404,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
)}
|
)}
|
||||||
onClick={selectExam}
|
onClick={selectExam}
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -291,11 +419,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
)}
|
)}
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,29 +444,46 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeInactiveAssignees = () => {
|
const removeInactiveAssignees = () => {
|
||||||
const mappedResults = mapBy(assignment.results, 'user')
|
const mappedResults = mapBy(assignment.results, "user");
|
||||||
const inactiveAssignees = assignment.assignees.filter((a) => !mappedResults.includes(a))
|
const inactiveAssignees = assignment.assignees.filter(
|
||||||
const activeAssignees = assignment.assignees.filter((a) => mappedResults.includes(a))
|
(a) => !mappedResults.includes(a)
|
||||||
|
);
|
||||||
|
const activeAssignees = assignment.assignees.filter((a) =>
|
||||||
|
mappedResults.includes(a)
|
||||||
|
);
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to remove ${inactiveAssignees.length} assignees?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/assignments/${assignment.id}`, { assignees: activeAssignees })
|
.patch(`/api/assignments/${assignment.id}`, {
|
||||||
|
assignees: activeAssignees,
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${assignment.name}" has been updated successfully!`);
|
toast.success(
|
||||||
|
`The assignment "${assignment.name}" has been updated successfully!`
|
||||||
|
);
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
toast.error("Something went wrong, please try again later!");
|
toast.error("Something went wrong, please try again later!");
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
const origin = window.location.origin
|
const origin = window.location.origin;
|
||||||
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`)
|
await navigator.clipboard.writeText(
|
||||||
toast.success("The URL to the assignment has been copied to your clipboard!")
|
`${origin}/exam?assignment=${assignment.id}`
|
||||||
}
|
);
|
||||||
|
toast.success(
|
||||||
|
"The URL to the assignment has been copied to your clipboard!"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -352,7 +500,10 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="w-full flex items-center justify-between">
|
<div className="w-full flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
<Link
|
||||||
|
href="/assignments"
|
||||||
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">{assignment.name}</h2>
|
<h2 className="font-bold text-2xl">{assignment.name}</h2>
|
||||||
@@ -371,16 +522,28 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
className="h-6"
|
className="h-6"
|
||||||
textClassName={
|
textClassName={
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5
|
(assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1) <
|
||||||
|
0.5
|
||||||
? "!text-mti-gray-dim font-light"
|
? "!text-mti-gray-dim font-light"
|
||||||
: "text-white"
|
: "text-white"
|
||||||
}
|
}
|
||||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
percentage={
|
||||||
|
((assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1)) *
|
||||||
|
100
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
Start Date:{" "}
|
||||||
|
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
End Date:{" "}
|
||||||
|
{moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>
|
||||||
@@ -390,12 +553,18 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
.map((u) => `${u.name} (${u.email})`)
|
.map((u) => `${u.name} (${u.email})`)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
|
<span>
|
||||||
|
Assigner:{" "}
|
||||||
|
{getUserName(users.find((x) => x.id === assignment?.assigner))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assignment.assignees.length !== 0 && assignment.results.length !== assignment.assignees.length && (
|
{assignment.assignees.length !== 0 &&
|
||||||
<Button onClick={removeInactiveAssignees} variant="outline">Remove Inactive Assignees</Button>
|
assignment.results.length !== assignment.assignees.length && (
|
||||||
|
<Button onClick={removeInactiveAssignees} variant="outline">
|
||||||
|
Remove Inactive Assignees
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -412,15 +581,22 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && (
|
||||||
|
<BsHeadphones className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
{module === "speaking" && (
|
||||||
|
<BsMegaphone className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -428,35 +604,59 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">
|
<span className="text-xl font-bold">
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
Results ({assignment?.results.length}/
|
||||||
|
{assignment?.assignees.length})
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{assignment && assignment?.results.length > 0 && (
|
{assignment && assignment?.results.length > 0 && (
|
||||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
{assignment && assignment?.results.length === 0 && (
|
||||||
|
<span className="ml-1 font-semibold">No results yet...</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-center justify-end">
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="purple"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={copyLink}
|
||||||
|
>
|
||||||
Copy Link
|
Copy Link
|
||||||
</Button>
|
</Button>
|
||||||
{assignment &&
|
{assignment &&
|
||||||
(assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
(assignment.results.length === assignment.assignees.length ||
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
moment().isAfter(moment(assignment.endDate))) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={deleteAssignment}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/** if the assignment is not deemed as active yet, display start */}
|
{/** if the assignment is not deemed as active yet, display start */}
|
||||||
{shouldRenderStart() && (
|
{shouldRenderStart() && (
|
||||||
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="green"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={startAssignment}
|
||||||
|
>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => router.push("/assignments")} className="w-full max-w-[200px]">
|
<Button
|
||||||
|
onClick={() => router.push("/assignments")}
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import { requestUser } from "@/utils/api";
|
|||||||
import { getAssignment } from "@/utils/assignments.be";
|
import { getAssignment } from "@/utils/assignments.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
||||||
import { checkAccess, doesEntityAllow, findAllowedEntities } from "@/utils/permissions";
|
import {
|
||||||
|
checkAccess,
|
||||||
|
doesEntityAllow,
|
||||||
|
findAllowedEntities,
|
||||||
|
} from "@/utils/permissions";
|
||||||
import { calculateAverageLevel } from "@/utils/score";
|
import { calculateAverageLevel } from "@/utils/score";
|
||||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -29,37 +33,93 @@ import moment from "moment";
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { generate } from "random-words";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import { BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs";
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsCheckCircle,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsXCircle,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
const user = await requestUser(req, res)
|
async ({ req, res, params }) => {
|
||||||
if (!user) return redirect("/login")
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
|
res.setHeader(
|
||||||
|
"Cache-Control",
|
||||||
|
"public, s-maxage=10, stale-while-revalidate=59"
|
||||||
|
);
|
||||||
|
|
||||||
const { id } = params as { id: string };
|
const { id } = params as { id: string };
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
|
const isAdmin = checkAccess(user, ["developer", "admin"]);
|
||||||
|
|
||||||
const assignment = await getAssignment(id);
|
const [assignment, entities] = await Promise.all([
|
||||||
if (!assignment) return redirect("/assignments")
|
getAssignment(id),
|
||||||
|
getEntitiesWithRoles(isAdmin ? undefined : entityIDS),
|
||||||
|
]);
|
||||||
|
|
||||||
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
|
if (!assignment) return redirect("/assignments");
|
||||||
const entity = entities.find((e) => e.id === assignment.entity)
|
const entity = entities.find((e) => e.id === assignment.entity);
|
||||||
|
|
||||||
if (!entity) return redirect("/assignments")
|
if (!entity) return redirect("/assignments");
|
||||||
if (!doesEntityAllow(user, entity, 'edit_assignment')) return redirect("/assignments")
|
|
||||||
|
|
||||||
const allowedEntities = findAllowedEntities(user, entities, 'edit_assignment')
|
if (!doesEntityAllow(user, entity, "edit_assignment"))
|
||||||
|
return redirect("/assignments");
|
||||||
|
|
||||||
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
|
const allowedEntities = findAllowedEntities(
|
||||||
const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id')));
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_assignment"
|
||||||
|
);
|
||||||
|
|
||||||
return { props: serialize({ user, users, entities: allowedEntities, assignment, groups }) };
|
const allowEntitiesIDs = mapBy(allowedEntities, "id");
|
||||||
}, sessionOptions);
|
|
||||||
|
const [users, groups] = await Promise.all([
|
||||||
|
isAdmin
|
||||||
|
? getUsers(
|
||||||
|
{},
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
type: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
levels: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: getEntitiesUsers(allowEntitiesIDs, {}, 0, {
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
type: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
levels: 1,
|
||||||
|
}),
|
||||||
|
isAdmin ? getGroups() : getGroupsByEntities(allowEntitiesIDs),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: serialize({
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
entities: allowedEntities,
|
||||||
|
assignment,
|
||||||
|
groups,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sessionOptions
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assignment: Assignment;
|
assignment: Assignment;
|
||||||
@@ -71,24 +131,44 @@ interface Props {
|
|||||||
|
|
||||||
const SIZE = 9;
|
const SIZE = 9;
|
||||||
|
|
||||||
export default function AssignmentsPage({ assignment, user, users, entities, groups }: Props) {
|
export default function AssignmentsPage({
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment.exams.map((e) => e.module));
|
assignment,
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
entities,
|
||||||
|
groups,
|
||||||
|
}: Props) {
|
||||||
|
const [selectedModules, setSelectedModules] = useState<Module[]>(
|
||||||
|
assignment.exams.map((e) => e.module)
|
||||||
|
);
|
||||||
const [assignees, setAssignees] = useState<string[]>(assignment.assignees);
|
const [assignees, setAssignees] = useState<string[]>(assignment.assignees);
|
||||||
const [teachers, setTeachers] = useState<string[]>(assignment.teachers || []);
|
const [teachers, setTeachers] = useState<string[]>(assignment.teachers || []);
|
||||||
const [entity, setEntity] = useState<string | undefined>(assignment.entity || entities[0]?.id);
|
const [entity, setEntity] = useState<string | undefined>(
|
||||||
|
assignment.entity || entities[0]?.id
|
||||||
|
);
|
||||||
const [name, setName] = useState(assignment.name);
|
const [name, setName] = useState(assignment.name);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [startDate, setStartDate] = useState<Date | null>(moment(assignment.startDate).toDate());
|
const [startDate, setStartDate] = useState<Date | null>(
|
||||||
const [endDate, setEndDate] = useState<Date | null>(moment(assignment.endDate).toDate());
|
moment(assignment.startDate).toDate()
|
||||||
|
);
|
||||||
|
const [endDate, setEndDate] = useState<Date | null>(
|
||||||
|
moment(assignment.endDate).toDate()
|
||||||
|
);
|
||||||
|
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
const [instructorGender, setInstructorGender] = useState<InstructorGender>(
|
||||||
|
assignment?.instructorGender || "varied"
|
||||||
|
);
|
||||||
|
|
||||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||||
const [released, setReleased] = useState<boolean>(assignment.released || false);
|
const [released, setReleased] = useState<boolean>(
|
||||||
|
assignment.released || false
|
||||||
|
);
|
||||||
|
|
||||||
const [autoStart, setAutostart] = useState<boolean>(assignment.autoStart || false);
|
const [autoStart, setAutostart] = useState<boolean>(
|
||||||
|
assignment.autoStart || false
|
||||||
|
);
|
||||||
|
|
||||||
const [useRandomExams, setUseRandomExams] = useState(true);
|
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||||
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
||||||
@@ -96,19 +176,34 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
const { exams } = useExams();
|
const { exams } = useExams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const classrooms = useMemo(() => groups.filter((e) => e.entity === entity), [entity, groups]);
|
const classrooms = useMemo(
|
||||||
|
() => groups.filter((e) => e.entity === entity),
|
||||||
|
[entity, groups]
|
||||||
|
);
|
||||||
|
|
||||||
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
|
const userStudents = useMemo(
|
||||||
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
|
() => users.filter((x) => x.type === "student"),
|
||||||
|
[users]
|
||||||
|
);
|
||||||
|
const userTeachers = useMemo(
|
||||||
|
() => users.filter((x) => x.type === "teacher"),
|
||||||
|
[users]
|
||||||
|
);
|
||||||
|
|
||||||
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } = useListSearch([["name"], ["email"]], userStudents);
|
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
|
||||||
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers);
|
useListSearch([["name"], ["email"]], userStudents);
|
||||||
|
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
|
||||||
|
useListSearch([["name"], ["email"]], userTeachers);
|
||||||
|
|
||||||
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE);
|
const { items: studentRows, renderMinimal: renderStudentPagination } =
|
||||||
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE);
|
usePagination(filteredStudentsRows, SIZE);
|
||||||
|
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
|
||||||
|
usePagination(filteredTeachersRows, SIZE);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
setExamIDs((prev) =>
|
||||||
|
prev.filter((x) => selectedModules.includes(x.module))
|
||||||
|
);
|
||||||
}, [selectedModules]);
|
}, [selectedModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -118,21 +213,33 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
setSelectedModules((prev) =>
|
||||||
|
prev.includes(module) ? modules : [...modules, module]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAssignee = (user: User) => {
|
const toggleAssignee = (user: User) => {
|
||||||
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
setAssignees((prev) =>
|
||||||
|
prev.includes(user.id)
|
||||||
|
? prev.filter((a) => a !== user.id)
|
||||||
|
: [...prev, user.id]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTeacher = (user: User) => {
|
const toggleTeacher = (user: User) => {
|
||||||
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
setTeachers((prev) =>
|
||||||
|
prev.includes(user.id)
|
||||||
|
? prev.filter((a) => a !== user.id)
|
||||||
|
: [...prev, user.id]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createAssignment = () => {
|
const createAssignment = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
(assignment ? axios.patch : axios.post)(`/api/assignments/${assignment.id}`, {
|
(assignment ? axios.patch : axios.post)(
|
||||||
|
`/api/assignments/${assignment.id}`,
|
||||||
|
{
|
||||||
assignees,
|
assignees,
|
||||||
name,
|
name,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -146,9 +253,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
instructorGender,
|
instructorGender,
|
||||||
released,
|
released,
|
||||||
autoStart,
|
autoStart,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${name}" has been updated successfully!`);
|
toast.success(
|
||||||
|
`The assignment "${name}" has been updated successfully!`
|
||||||
|
);
|
||||||
router.push(`/assignments/${assignment.id}`);
|
router.push(`/assignments/${assignment.id}`);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -159,14 +269,21 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteAssignment = () => {
|
const deleteAssignment = () => {
|
||||||
if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to delete the "${assignment.name}" assignment?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
console.log("GOT HERE");
|
console.log("GOT HERE");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/assignments/${assignment.id}`)
|
.delete(`/api/assignments/${assignment.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${name}" has been deleted successfully!`);
|
toast.success(
|
||||||
|
`The assignment "${name}" has been deleted successfully!`
|
||||||
|
);
|
||||||
router.push("/assignments");
|
router.push("/assignments");
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -183,7 +300,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
axios
|
axios
|
||||||
.post(`/api/assignments/${assignment.id}/start`)
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${name}" has been started successfully!`);
|
toast.success(
|
||||||
|
`The assignment "${name}" has been started successfully!`
|
||||||
|
);
|
||||||
router.push(`/assignments/${assignment.id}`);
|
router.push(`/assignments/${assignment.id}`);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -195,10 +314,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
const origin = window.location.origin
|
const origin = window.location.origin;
|
||||||
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`)
|
await navigator.clipboard.writeText(
|
||||||
toast.success("The URL to the assignment has been copied to your clipboard!")
|
`${origin}/exam?assignment=${assignment.id}`
|
||||||
}
|
);
|
||||||
|
toast.success(
|
||||||
|
"The URL to the assignment has been copied to your clipboard!"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -214,7 +337,10 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
<Link
|
||||||
|
href="/assignments"
|
||||||
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">Edit {assignment.name}</h2>
|
<h2 className="font-bold text-2xl">Edit {assignment.name}</h2>
|
||||||
@@ -224,109 +350,180 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4">
|
||||||
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("reading")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("reading")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsBook className="text-white w-7 h-7" />
|
<BsBook className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Reading</span>
|
<span className="ml-8 font-semibold">Reading</span>
|
||||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("reading") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("reading") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("listening")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("listening")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsHeadphones className="text-white w-7 h-7" />
|
<BsHeadphones className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Listening</span>
|
<span className="ml-8 font-semibold">Listening</span>
|
||||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("listening") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("listening") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={
|
onClick={
|
||||||
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
|
(!selectedModules.includes("level") &&
|
||||||
|
selectedModules.length === 0) ||
|
||||||
|
selectedModules.includes("level")
|
||||||
? () => toggleModule("level")
|
? () => toggleModule("level")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("level")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsClipboard className="text-white w-7 h-7" />
|
<BsClipboard className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Level</span>
|
<span className="ml-8 font-semibold">Level</span>
|
||||||
{!selectedModules.includes("level") && selectedModules.length === 0 && (
|
{!selectedModules.includes("level") &&
|
||||||
|
selectedModules.length === 0 && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{!selectedModules.includes("level") &&
|
||||||
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
selectedModules.length > 0 && (
|
||||||
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("level") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("writing")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("writing")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsPen className="text-white w-7 h-7" />
|
<BsPen className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Writing</span>
|
<span className="ml-8 font-semibold">Writing</span>
|
||||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("writing") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("writing") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("speaking")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("speaking")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsMegaphone className="text-white w-7 h-7" />
|
<BsMegaphone className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Speaking</span>
|
<span className="ml-8 font-semibold">Speaking</span>
|
||||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("speaking") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("speaking") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
onChange={(e) => setName(e)}
|
||||||
|
defaultValue={name}
|
||||||
|
label="Assignment Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Entity"
|
label="Entity"
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
||||||
defaultValue={{ value: entities[0]?.id, label: entities[0]?.label }}
|
defaultValue={{
|
||||||
|
value: entities[0]?.id,
|
||||||
|
label: entities[0]?.label,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Limit Start Date *
|
||||||
|
</label>
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"hover:border-mti-purple tooltip z-10",
|
"hover:border-mti-purple tooltip z-10",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||||
@@ -337,12 +534,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
End Date *
|
||||||
|
</label>
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"hover:border-mti-purple tooltip z-10",
|
"hover:border-mti-purple tooltip z-10",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterTime={(date) => moment(date).isAfter(startDate)}
|
filterTime={(date) => moment(date).isAfter(startDate)}
|
||||||
@@ -356,13 +555,19 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
|
|
||||||
{selectedModules.includes("speaking") && (
|
{selectedModules.includes("speaking") && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Speaking Instructor's Gender
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={{
|
value={{
|
||||||
value: instructorGender,
|
value: instructorGender,
|
||||||
label: capitalize(instructorGender),
|
label: capitalize(instructorGender),
|
||||||
}}
|
}}
|
||||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
onChange={(value) =>
|
||||||
|
value
|
||||||
|
? setInstructorGender(value.value as InstructorGender)
|
||||||
|
: null
|
||||||
|
}
|
||||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||||
options={[
|
options={[
|
||||||
{ value: "male", label: "Male" },
|
{ value: "male", label: "Male" },
|
||||||
@@ -382,11 +587,16 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
<div className="grid md:grid-cols-2 w-full gap-4">
|
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||||
{selectedModules.map((module) => (
|
{selectedModules.map((module) => (
|
||||||
<div key={module} className="flex flex-col gap-3 w-full">
|
<div key={module} className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
{capitalize(module)} Exam
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={{
|
value={{
|
||||||
value: examIDs.find((e) => e.module === module)?.id || null,
|
value:
|
||||||
label: examIDs.find((e) => e.module === module)?.id || "",
|
examIDs.find((e) => e.module === module)?.id ||
|
||||||
|
null,
|
||||||
|
label:
|
||||||
|
examIDs.find((e) => e.module === module)?.id || "",
|
||||||
}}
|
}}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
value
|
value
|
||||||
@@ -394,7 +604,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
...prev.filter((x) => x.module !== module),
|
...prev.filter((x) => x.module !== module),
|
||||||
{ id: value.value!, module },
|
{ id: value.value!, module },
|
||||||
])
|
])
|
||||||
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
|
: setExamIDs((prev) =>
|
||||||
|
prev.filter((x) => x.module !== module)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
options={exams
|
options={exams
|
||||||
.filter((x) => !x.isDiagnostic && x.module === module)
|
.filter((x) => !x.isDiagnostic && x.module === module)
|
||||||
@@ -408,25 +620,40 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="w-full flex flex-col gap-4">
|
<section className="w-full flex flex-col gap-4">
|
||||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
<span className="font-semibold">
|
||||||
|
Assignees ({assignees.length} selected)
|
||||||
|
</span>
|
||||||
<div className="grid grid-cols-5 gap-4">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{classrooms.map((g) => (
|
{classrooms.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
const groupStudentIds = users.reduce<string[]>((acc, u) => {
|
||||||
|
if (g.participants.includes(u.id)) {
|
||||||
|
acc.push(u.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
if (groupStudentIds.every((u) => assignees.includes(u))) {
|
if (groupStudentIds.every((u) => assignees.includes(u))) {
|
||||||
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
setAssignees((prev) =>
|
||||||
|
prev.filter((a) => !groupStudentIds.includes(a))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
setAssignees((prev) => [
|
||||||
|
...prev.filter((a) => !groupStudentIds.includes(a)),
|
||||||
|
...groupStudentIds,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
|
users
|
||||||
"!bg-mti-purple-light !text-white",
|
.filter((u) => g.participants.includes(u.id))
|
||||||
)}>
|
.every((u) => assignees.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{g.name}
|
{g.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -444,9 +671,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
assignees.includes(user.id)
|
||||||
|
? "border-mti-purple"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
)}
|
)}
|
||||||
key={user.id}>
|
key={user.id}
|
||||||
|
>
|
||||||
<span className="flex flex-col gap-0 justify-center">
|
<span className="flex flex-col gap-0 justify-center">
|
||||||
<span className="font-semibold">{user.name}</span>
|
<span className="font-semibold">{user.name}</span>
|
||||||
<span className="text-sm opacity-80">{user.email}</span>
|
<span className="text-sm opacity-80">{user.email}</span>
|
||||||
@@ -472,25 +702,43 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
|
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-3">
|
||||||
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
|
<span className="font-semibold">
|
||||||
|
Teachers ({teachers.length} selected)
|
||||||
|
</span>
|
||||||
<div className="grid grid-cols-5 gap-4">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{classrooms.map((g) => (
|
{classrooms.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
const groupStudentIds = users.reduce<string[]>(
|
||||||
|
(acc, u) => {
|
||||||
|
if (g.participants.includes(u.id)) {
|
||||||
|
acc.push(u.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
if (groupStudentIds.every((u) => teachers.includes(u))) {
|
if (groupStudentIds.every((u) => teachers.includes(u))) {
|
||||||
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
setTeachers((prev) =>
|
||||||
|
prev.filter((a) => !groupStudentIds.includes(a))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
setTeachers((prev) => [
|
||||||
|
...prev.filter((a) => !groupStudentIds.includes(a)),
|
||||||
|
...groupStudentIds,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
|
users
|
||||||
"!bg-mti-purple-light !text-white",
|
.filter((u) => g.participants.includes(u.id))
|
||||||
)}>
|
.every((u) => teachers.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{g.name}
|
{g.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -508,9 +756,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
teachers.includes(user.id)
|
||||||
|
? "border-mti-purple"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
)}
|
)}
|
||||||
key={user.id}>
|
key={user.id}
|
||||||
|
>
|
||||||
<span className="flex flex-col gap-0 justify-center">
|
<span className="flex flex-col gap-0 justify-center">
|
||||||
<span className="font-semibold">{user.name}</span>
|
<span className="font-semibold">{user.name}</span>
|
||||||
<span className="text-sm opacity-80">{user.email}</span>
|
<span className="text-sm opacity-80">{user.email}</span>
|
||||||
@@ -529,21 +780,40 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-end">
|
<div className="flex gap-4 w-full items-end">
|
||||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
<Checkbox
|
||||||
|
isChecked={variant === "full"}
|
||||||
|
onChange={() =>
|
||||||
|
setVariant((prev) => (prev === "full" ? "partial" : "full"))
|
||||||
|
}
|
||||||
|
>
|
||||||
Full length exams
|
Full length exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
<Checkbox
|
||||||
|
isChecked={generateMultiple}
|
||||||
|
onChange={() => setGenerateMultiple((d) => !d)}
|
||||||
|
>
|
||||||
Generate different exams
|
Generate different exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
|
<Checkbox
|
||||||
|
isChecked={released}
|
||||||
|
onChange={() => setReleased((d) => !d)}
|
||||||
|
>
|
||||||
Auto release results
|
Auto release results
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
|
<Checkbox
|
||||||
|
isChecked={autoStart}
|
||||||
|
onChange={() => setAutostart((d) => !d)}
|
||||||
|
>
|
||||||
Auto start exam
|
Auto start exam
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 w-full justify-end">
|
<div className="flex gap-4 w-full justify-end">
|
||||||
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="purple"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={copyLink}
|
||||||
|
>
|
||||||
Copy Link
|
Copy Link
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -551,7 +821,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -560,7 +831,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={startAssignment}
|
onClick={startAssignment}
|
||||||
disabled={isLoading || moment().isAfter(startDate)}
|
disabled={isLoading || moment().isAfter(startDate)}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -569,7 +841,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={deleteAssignment}
|
onClick={deleteAssignment}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -583,7 +856,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
|
|||||||
}
|
}
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]"
|
||||||
onClick={createAssignment}
|
onClick={createAssignment}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,21 +32,61 @@ import { useRouter } from "next/router";
|
|||||||
import { generate } from "random-words";
|
import { generate } from "random-words";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import { BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs";
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsCheckCircle,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsXCircle,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
|
const entities = await (checkAccess(user, ["developer", "admin"])
|
||||||
|
? getEntitiesWithRoles()
|
||||||
|
: getEntitiesWithRoles(entityIDS));
|
||||||
|
|
||||||
const allowedEntities = findAllowedEntities(user, entities, 'create_assignment')
|
const allowedEntities = findAllowedEntities(
|
||||||
if (allowedEntities.length === 0) return redirect("/assignments")
|
user,
|
||||||
|
entities,
|
||||||
|
"create_assignment"
|
||||||
|
);
|
||||||
|
if (allowedEntities.length === 0) return redirect("/assignments");
|
||||||
|
|
||||||
const users = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
|
const [users, groups] = await Promise.all([
|
||||||
const groups = await (isAdmin(user) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id')));
|
isAdmin(user)
|
||||||
|
? getUsers(
|
||||||
|
{},
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
type: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
levels: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
type: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
levels: 1,
|
||||||
|
}),
|
||||||
|
isAdmin(user)
|
||||||
|
? getGroups()
|
||||||
|
: getGroupsByEntities(mapBy(allowedEntities, "id")),
|
||||||
|
]);
|
||||||
|
|
||||||
return { props: serialize({ user, users, entities, groups }) };
|
return { props: serialize({ user, users, entities, groups }) };
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
@@ -61,10 +101,17 @@ interface Props {
|
|||||||
|
|
||||||
const SIZE = 9;
|
const SIZE = 9;
|
||||||
|
|
||||||
export default function AssignmentsPage({ user, users, groups, entities }: Props) {
|
export default function AssignmentsPage({
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
groups,
|
||||||
|
entities,
|
||||||
|
}: Props) {
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
const [assignees, setAssignees] = useState<string[]>([]);
|
const [assignees, setAssignees] = useState<string[]>([]);
|
||||||
const [teachers, setTeachers] = useState<string[]>([...(user.type === "teacher" ? [user.id] : [])]);
|
const [teachers, setTeachers] = useState<string[]>([
|
||||||
|
...(user.type === "teacher" ? [user.id] : []),
|
||||||
|
]);
|
||||||
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
|
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
|
||||||
const [name, setName] = useState(
|
const [name, setName] = useState(
|
||||||
generate({
|
generate({
|
||||||
@@ -74,14 +121,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
max: 3,
|
max: 3,
|
||||||
join: " ",
|
join: " ",
|
||||||
formatter: capitalize,
|
formatter: capitalize,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [startDate, setStartDate] = useState<Date | null>(moment().add(1, "hour").toDate());
|
const [startDate, setStartDate] = useState<Date | null>(
|
||||||
|
moment().add(1, "hour").toDate()
|
||||||
|
);
|
||||||
|
|
||||||
const [endDate, setEndDate] = useState<Date | null>(moment().hours(23).minutes(59).add(8, "day").toDate());
|
const [endDate, setEndDate] = useState<Date | null>(
|
||||||
|
moment().hours(23).minutes(59).add(8, "day").toDate()
|
||||||
|
);
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>("varied");
|
const [instructorGender, setInstructorGender] =
|
||||||
|
useState<InstructorGender>("varied");
|
||||||
|
|
||||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||||
const [released, setReleased] = useState<boolean>(false);
|
const [released, setReleased] = useState<boolean>(false);
|
||||||
@@ -91,23 +143,44 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
const [useRandomExams, setUseRandomExams] = useState(true);
|
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||||
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
||||||
|
|
||||||
|
const [showApprovedExams, setShowApprovedExams] = useState<boolean>(true);
|
||||||
|
const [showNonApprovedExams, setShowNonApprovedExams] = useState<boolean>(true);
|
||||||
|
|
||||||
const { exams } = useExams();
|
const { exams } = useExams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const classrooms = useMemo(() => groups.filter((e) => e.entity?.id === entity), [entity, groups]);
|
const classrooms = useMemo(
|
||||||
const allowedUsers = useMemo(() => users.filter((u) => mapBy(u.entities, 'id').includes(entity || "")), [users, entity])
|
() => groups.filter((e) => e.entity?.id === entity),
|
||||||
|
[entity, groups]
|
||||||
|
);
|
||||||
|
const allowedUsers = useMemo(
|
||||||
|
() => users.filter((u) => mapBy(u.entities, "id").includes(entity || "")),
|
||||||
|
[users, entity]
|
||||||
|
);
|
||||||
|
|
||||||
const userStudents = useMemo(() => allowedUsers.filter((x) => x.type === "student"), [allowedUsers]);
|
const userStudents = useMemo(
|
||||||
const userTeachers = useMemo(() => allowedUsers.filter((x) => x.type === "teacher"), [allowedUsers]);
|
() => allowedUsers.filter((x) => x.type === "student"),
|
||||||
|
[allowedUsers]
|
||||||
|
);
|
||||||
|
const userTeachers = useMemo(
|
||||||
|
() => allowedUsers.filter((x) => x.type === "teacher"),
|
||||||
|
[allowedUsers]
|
||||||
|
);
|
||||||
|
|
||||||
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } = useListSearch([["name"], ["email"]], userStudents);
|
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
|
||||||
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers);
|
useListSearch([["name"], ["email"]], userStudents);
|
||||||
|
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
|
||||||
|
useListSearch([["name"], ["email"]], userTeachers);
|
||||||
|
|
||||||
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE);
|
const { items: studentRows, renderMinimal: renderStudentPagination } =
|
||||||
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE);
|
usePagination(filteredStudentsRows, SIZE);
|
||||||
|
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
|
||||||
|
usePagination(filteredTeachersRows, SIZE);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
setExamIDs((prev) =>
|
||||||
|
prev.filter((x) => selectedModules.includes(x.module))
|
||||||
|
);
|
||||||
}, [selectedModules]);
|
}, [selectedModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,15 +190,25 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
setSelectedModules((prev) =>
|
||||||
|
prev.includes(module) ? modules : [...modules, module]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAssignee = (user: User) => {
|
const toggleAssignee = (user: User) => {
|
||||||
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
setAssignees((prev) =>
|
||||||
|
prev.includes(user.id)
|
||||||
|
? prev.filter((a) => a !== user.id)
|
||||||
|
: [...prev, user.id]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTeacher = (user: User) => {
|
const toggleTeacher = (user: User) => {
|
||||||
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
setTeachers((prev) =>
|
||||||
|
prev.includes(user.id)
|
||||||
|
? prev.filter((a) => a !== user.id)
|
||||||
|
: [...prev, user.id]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createAssignment = () => {
|
const createAssignment = () => {
|
||||||
@@ -148,7 +231,9 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
autoStart,
|
autoStart,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
toast.success(`The assignment "${name}" has been created successfully!`);
|
toast.success(
|
||||||
|
`The assignment "${name}" has been created successfully!`
|
||||||
|
);
|
||||||
router.push(`/assignments/${result.data.id}`);
|
router.push(`/assignments/${result.data.id}`);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -172,7 +257,10 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
<Link
|
||||||
|
href="/assignments"
|
||||||
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">Create Assignment</h2>
|
<h2 className="font-bold text-2xl">Create Assignment</h2>
|
||||||
@@ -182,109 +270,180 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4">
|
||||||
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("reading")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("reading")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsBook className="text-white w-7 h-7" />
|
<BsBook className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Reading</span>
|
<span className="ml-8 font-semibold">Reading</span>
|
||||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("reading") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("reading") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("listening")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("listening")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsHeadphones className="text-white w-7 h-7" />
|
<BsHeadphones className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Listening</span>
|
<span className="ml-8 font-semibold">Listening</span>
|
||||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("listening") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("listening") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={
|
onClick={
|
||||||
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
|
(!selectedModules.includes("level") &&
|
||||||
|
selectedModules.length === 0) ||
|
||||||
|
selectedModules.includes("level")
|
||||||
? () => toggleModule("level")
|
? () => toggleModule("level")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("level")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsClipboard className="text-white w-7 h-7" />
|
<BsClipboard className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Level</span>
|
<span className="ml-8 font-semibold">Level</span>
|
||||||
{!selectedModules.includes("level") && selectedModules.length === 0 && (
|
{!selectedModules.includes("level") &&
|
||||||
|
selectedModules.length === 0 && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{!selectedModules.includes("level") &&
|
||||||
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
selectedModules.length > 0 && (
|
||||||
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("level") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("writing")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("writing")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsPen className="text-white w-7 h-7" />
|
<BsPen className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Writing</span>
|
<span className="ml-8 font-semibold">Writing</span>
|
||||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("writing") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("writing") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
onClick={
|
||||||
|
!selectedModules.includes("level")
|
||||||
|
? () => toggleModule("speaking")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("speaking")
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
<BsMegaphone className="text-white w-7 h-7" />
|
<BsMegaphone className="text-white w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-8 font-semibold">Speaking</span>
|
<span className="ml-8 font-semibold">Speaking</span>
|
||||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
|
{!selectedModules.includes("speaking") &&
|
||||||
|
!selectedModules.includes("level") && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && (
|
||||||
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
<BsXCircle className="text-mti-red-light w-8 h-8" />
|
||||||
|
)}
|
||||||
|
{selectedModules.includes("speaking") && (
|
||||||
|
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
onChange={(e) => setName(e)}
|
||||||
|
defaultValue={name}
|
||||||
|
label="Assignment Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Entity"
|
label="Entity"
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
||||||
defaultValue={{ value: entities[0]?.id, label: entities[0]?.label }}
|
defaultValue={{
|
||||||
|
value: entities[0]?.id,
|
||||||
|
label: entities[0]?.label,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Limit Start Date *
|
||||||
|
</label>
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"hover:border-mti-purple tooltip z-10",
|
"hover:border-mti-purple tooltip z-10",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||||
@@ -295,12 +454,14 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
End Date *
|
||||||
|
</label>
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"hover:border-mti-purple tooltip z-10",
|
"hover:border-mti-purple tooltip z-10",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterTime={(date) => moment(date).isAfter(startDate)}
|
filterTime={(date) => moment(date).isAfter(startDate)}
|
||||||
@@ -314,13 +475,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
|
|
||||||
{selectedModules.includes("speaking") && (
|
{selectedModules.includes("speaking") && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Speaking Instructor's Gender
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={{
|
value={{
|
||||||
value: instructorGender,
|
value: instructorGender,
|
||||||
label: capitalize(instructorGender),
|
label: capitalize(instructorGender),
|
||||||
}}
|
}}
|
||||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
onChange={(value) =>
|
||||||
|
value
|
||||||
|
? setInstructorGender(value.value as InstructorGender)
|
||||||
|
: null
|
||||||
|
}
|
||||||
disabled={!selectedModules.includes("speaking")}
|
disabled={!selectedModules.includes("speaking")}
|
||||||
options={[
|
options={[
|
||||||
{ value: "male", label: "Male" },
|
{ value: "male", label: "Male" },
|
||||||
@@ -337,14 +504,37 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
Random Exams
|
Random Exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
{!useRandomExams && (
|
{!useRandomExams && (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={showApprovedExams}
|
||||||
|
onChange={() => {
|
||||||
|
setShowApprovedExams((prev) => !prev)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show approved exams
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={showNonApprovedExams}
|
||||||
|
onChange={() => {
|
||||||
|
setShowNonApprovedExams((prev) => !prev)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show non-approved exams
|
||||||
|
</Checkbox>
|
||||||
<div className="grid md:grid-cols-2 w-full gap-4">
|
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||||
{selectedModules.map((module) => (
|
{selectedModules.map((module) => (
|
||||||
<div key={module} className="flex flex-col gap-3 w-full">
|
<div key={module} className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
{capitalize(module)} Exam
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
isClearable
|
||||||
value={{
|
value={{
|
||||||
value: examIDs.find((e) => e.module === module)?.id || null,
|
value:
|
||||||
label: examIDs.find((e) => e.module === module)?.id || "",
|
examIDs.find((e) => e.module === module)?.id ||
|
||||||
|
null,
|
||||||
|
label:
|
||||||
|
examIDs.find((e) => e.module === module)?.id || "",
|
||||||
}}
|
}}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
value
|
value
|
||||||
@@ -352,39 +542,65 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
...prev.filter((x) => x.module !== module),
|
...prev.filter((x) => x.module !== module),
|
||||||
{ id: value.value!, module },
|
{ id: value.value!, module },
|
||||||
])
|
])
|
||||||
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
|
: setExamIDs((prev) =>
|
||||||
|
prev.filter((x) => x.module !== module)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
options={exams
|
options={exams
|
||||||
.filter((x) => !x.isDiagnostic && x.module === module)
|
.filter((x) =>
|
||||||
|
!x.isDiagnostic &&
|
||||||
|
x.module === module &&
|
||||||
|
x.access !== "confidential" &&
|
||||||
|
(
|
||||||
|
(x.requiresApproval && showApprovedExams) ||
|
||||||
|
(!x.requiresApproval && showNonApprovedExams)
|
||||||
|
)
|
||||||
|
)
|
||||||
.map((x) => ({ value: x.id, label: x.id }))}
|
.map((x) => ({ value: x.id, label: x.id }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="w-full flex flex-col gap-4">
|
<section className="w-full flex flex-col gap-4">
|
||||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
<span className="font-semibold">
|
||||||
|
Assignees ({assignees.length} selected)
|
||||||
|
</span>
|
||||||
<div className="grid grid-cols-5 gap-4">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{classrooms.map((g) => (
|
{classrooms.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
const groupStudentIds = users.reduce<string[]>((acc, u) => {
|
||||||
|
if (g.participants.includes(u.id)) {
|
||||||
|
acc.push(u.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
if (groupStudentIds.every((u) => assignees.includes(u))) {
|
if (groupStudentIds.every((u) => assignees.includes(u))) {
|
||||||
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
setAssignees((prev) =>
|
||||||
|
prev.filter((a) => !groupStudentIds.includes(a))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
setAssignees((prev) => [
|
||||||
|
...prev.filter((a) => !groupStudentIds.includes(a)),
|
||||||
|
...groupStudentIds,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
|
users
|
||||||
"!bg-mti-purple-light !text-white",
|
.filter((u) => g.participants.includes(u.id))
|
||||||
)}>
|
.every((u) => assignees.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{g.name}
|
{g.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -402,9 +618,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
assignees.includes(user.id)
|
||||||
|
? "border-mti-purple"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
)}
|
)}
|
||||||
key={user.id}>
|
key={user.id}
|
||||||
|
>
|
||||||
<span className="flex flex-col gap-0 justify-center">
|
<span className="flex flex-col gap-0 justify-center">
|
||||||
<span className="font-semibold">{user.name}</span>
|
<span className="font-semibold">{user.name}</span>
|
||||||
<span className="text-sm opacity-80">{user.email}</span>
|
<span className="text-sm opacity-80">{user.email}</span>
|
||||||
@@ -430,25 +649,43 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
|
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-3">
|
||||||
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
|
<span className="font-semibold">
|
||||||
|
Teachers ({teachers.length} selected)
|
||||||
|
</span>
|
||||||
<div className="grid grid-cols-5 gap-4">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{classrooms.map((g) => (
|
{classrooms.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
const groupStudentIds = users.reduce<string[]>(
|
||||||
|
(acc, u) => {
|
||||||
|
if (g.participants.includes(u.id)) {
|
||||||
|
acc.push(u.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
if (groupStudentIds.every((u) => teachers.includes(u))) {
|
if (groupStudentIds.every((u) => teachers.includes(u))) {
|
||||||
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
setTeachers((prev) =>
|
||||||
|
prev.filter((a) => !groupStudentIds.includes(a))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
setTeachers((prev) => [
|
||||||
|
...prev.filter((a) => !groupStudentIds.includes(a)),
|
||||||
|
...groupStudentIds,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
|
users
|
||||||
"!bg-mti-purple-light !text-white",
|
.filter((u) => g.participants.includes(u.id))
|
||||||
)}>
|
.every((u) => teachers.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{g.name}
|
{g.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -466,9 +703,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
teachers.includes(user.id)
|
||||||
|
? "border-mti-purple"
|
||||||
|
: "border-mti-gray-platinum"
|
||||||
)}
|
)}
|
||||||
key={user.id}>
|
key={user.id}
|
||||||
|
>
|
||||||
<span className="flex flex-col gap-0 justify-center">
|
<span className="flex flex-col gap-0 justify-center">
|
||||||
<span className="font-semibold">{user.name}</span>
|
<span className="font-semibold">{user.name}</span>
|
||||||
<span className="text-sm opacity-80">{user.email}</span>
|
<span className="text-sm opacity-80">{user.email}</span>
|
||||||
@@ -487,16 +727,30 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-end">
|
<div className="flex gap-4 w-full items-end">
|
||||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
<Checkbox
|
||||||
|
isChecked={variant === "full"}
|
||||||
|
onChange={() =>
|
||||||
|
setVariant((prev) => (prev === "full" ? "partial" : "full"))
|
||||||
|
}
|
||||||
|
>
|
||||||
Full length exams
|
Full length exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
<Checkbox
|
||||||
|
isChecked={generateMultiple}
|
||||||
|
onChange={() => setGenerateMultiple((d) => !d)}
|
||||||
|
>
|
||||||
Generate different exams
|
Generate different exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
|
<Checkbox
|
||||||
|
isChecked={released}
|
||||||
|
onChange={() => setReleased((d) => !d)}
|
||||||
|
>
|
||||||
Auto release results
|
Auto release results
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
|
<Checkbox
|
||||||
|
isChecked={autoStart}
|
||||||
|
onChange={() => setAutostart((d) => !d)}
|
||||||
|
>
|
||||||
Auto start exam
|
Auto start exam
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +760,8 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -522,7 +777,8 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
|
|||||||
}
|
}
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]"
|
||||||
onClick={createAssignment}
|
onClick={createAssignment}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,59 +28,137 @@ import { useMemo } from "react";
|
|||||||
import { BsChevronLeft, BsPlus } from "react-icons/bs";
|
import { BsChevronLeft, BsPlus } from "react-icons/bs";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
|
|
||||||
return redirect("/")
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
!checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"corporate",
|
||||||
|
"teacher",
|
||||||
|
"mastercorporate",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return redirect("/");
|
||||||
|
const isAdmin = checkAccess(user, ["developer", "admin"]);
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
|
const entities = await (isAdmin
|
||||||
|
? getEntitiesWithRoles()
|
||||||
|
: getEntitiesWithRoles(entityIDS));
|
||||||
|
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "view_assignments")
|
const allowedEntities = findAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"view_assignments"
|
||||||
|
);
|
||||||
|
const [users, assignments] = await Promise.all([
|
||||||
|
await (isAdmin
|
||||||
|
? getUsers({}, 0, {}, { _id: 0, id: 1, name: 1 })
|
||||||
|
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
})),
|
||||||
|
await (isAdmin
|
||||||
|
? getAssignments()
|
||||||
|
: getEntitiesAssignments(mapBy(allowedEntities, "id"))),
|
||||||
|
]);
|
||||||
|
|
||||||
const users =
|
return {
|
||||||
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
|
props: serialize({ user, users, entities: allowedEntities, assignments }),
|
||||||
|
};
|
||||||
const assignments =
|
|
||||||
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
|
|
||||||
|
|
||||||
return { props: serialize({ user, users, entities: allowedEntities, assignments }) };
|
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
const SEARCH_FIELDS = [["name"]];
|
const SEARCH_FIELDS = [["name"]];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentsPage({ assignments, entities, user, users }: Props) {
|
export default function AssignmentsPage({
|
||||||
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment')
|
assignments,
|
||||||
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment')
|
entities,
|
||||||
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment')
|
user,
|
||||||
|
users,
|
||||||
|
}: Props) {
|
||||||
|
const entitiesAllowCreate = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"create_assignment"
|
||||||
|
);
|
||||||
|
const entitiesAllowEdit = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_assignment"
|
||||||
|
);
|
||||||
|
const entitiesAllowArchive = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"archive_assignment"
|
||||||
|
);
|
||||||
|
|
||||||
const activeAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]);
|
const activeAssignments = useMemo(
|
||||||
const plannedAssignments = useMemo(() => assignments.filter(futureAssignmentFilter), [assignments]);
|
() => assignments.filter(activeAssignmentFilter),
|
||||||
const pastAssignments = useMemo(() => assignments.filter(pastAssignmentFilter), [assignments]);
|
[assignments]
|
||||||
const startExpiredAssignments = useMemo(() => assignments.filter(startHasExpiredAssignmentFilter), [assignments]);
|
);
|
||||||
const archivedAssignments = useMemo(() => assignments.filter(archivedAssignmentFilter), [assignments]);
|
const plannedAssignments = useMemo(
|
||||||
|
() => assignments.filter(futureAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
const pastAssignments = useMemo(
|
||||||
|
() => assignments.filter(pastAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
const startExpiredAssignments = useMemo(
|
||||||
|
() => assignments.filter(startHasExpiredAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
const archivedAssignments = useMemo(
|
||||||
|
() => assignments.filter(archivedAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { rows: activeRows, renderSearch: renderActive } = useListSearch(SEARCH_FIELDS, activeAssignments);
|
const { rows: activeRows, renderSearch: renderActive } = useListSearch(
|
||||||
const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(SEARCH_FIELDS, plannedAssignments);
|
SEARCH_FIELDS,
|
||||||
const { rows: pastRows, renderSearch: renderPast } = useListSearch(SEARCH_FIELDS, pastAssignments);
|
activeAssignments
|
||||||
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(SEARCH_FIELDS, startExpiredAssignments);
|
);
|
||||||
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(SEARCH_FIELDS, archivedAssignments);
|
const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
plannedAssignments
|
||||||
|
);
|
||||||
|
const { rows: pastRows, renderSearch: renderPast } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
pastAssignments
|
||||||
|
);
|
||||||
|
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
startExpiredAssignments
|
||||||
|
);
|
||||||
|
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
archivedAssignments
|
||||||
|
);
|
||||||
|
|
||||||
const { items: activeItems, renderMinimal: paginationActive } = usePagination(activeRows, 16);
|
const { items: activeItems, renderMinimal: paginationActive } = usePagination(
|
||||||
const { items: plannedItems, renderMinimal: paginationPlanned } = usePagination(plannedRows, 16);
|
activeRows,
|
||||||
const { items: pastItems, renderMinimal: paginationPast } = usePagination(pastRows, 16);
|
16
|
||||||
const { items: expiredItems, renderMinimal: paginationExpired } = usePagination(expiredRows, 16);
|
);
|
||||||
const { items: archivedItems, renderMinimal: paginationArchived } = usePagination(archivedRows, 16);
|
const { items: plannedItems, renderMinimal: paginationPlanned } =
|
||||||
|
usePagination(plannedRows, 16);
|
||||||
|
const { items: pastItems, renderMinimal: paginationPast } = usePagination(
|
||||||
|
pastRows,
|
||||||
|
16
|
||||||
|
);
|
||||||
|
const { items: expiredItems, renderMinimal: paginationExpired } =
|
||||||
|
usePagination(expiredRows, 16);
|
||||||
|
const { items: archivedItems, renderMinimal: paginationArchived } =
|
||||||
|
usePagination(archivedRows, 16);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -96,7 +174,10 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">Assignments</h2>
|
<h2 className="font-bold text-2xl">Assignments</h2>
|
||||||
@@ -107,35 +188,56 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
|
|||||||
<span className="text-lg font-bold">Active Assignments Status</span>
|
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>
|
<span>
|
||||||
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/
|
<b>Total:</b>{" "}
|
||||||
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)}
|
{activeAssignments.reduce(
|
||||||
|
(acc, curr) => acc + curr.results.length,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
/
|
||||||
|
{activeAssignments.reduce(
|
||||||
|
(acc, curr) => curr.exams.length + acc,
|
||||||
|
0
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Active Assignments ({activeAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Active Assignments ({activeAssignments.length})
|
||||||
|
</h2>
|
||||||
<div className="w-full flex items-center gap-4">
|
<div className="w-full flex items-center gap-4">
|
||||||
{renderActive()}
|
{renderActive()}
|
||||||
{paginationActive()}
|
{paginationActive()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{activeItems.map((a) => (
|
{activeItems.map((a) => (
|
||||||
<AssignmentCard {...a} entityObj={findBy(entities, 'id', a.entity)} users={users} onClick={() => router.push(`/assignments/${a.id}`)} key={a.id} />
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
|
users={users}
|
||||||
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
|
key={a.id}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Planned Assignments ({plannedAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Planned Assignments ({plannedAssignments.length})
|
||||||
|
</h2>
|
||||||
<div className="w-full flex items-center gap-4">
|
<div className="w-full flex items-center gap-4">
|
||||||
{renderPlanned()}
|
{renderPlanned()}
|
||||||
{paginationPlanned()}
|
{paginationPlanned()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""}
|
href={
|
||||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""
|
||||||
|
}
|
||||||
|
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsPlus className="text-6xl" />
|
<BsPlus className="text-6xl" />
|
||||||
<span className="text-lg">New Assignment</span>
|
<span className="text-lg">New Assignment</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -143,9 +245,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
|
|||||||
<AssignmentCard
|
<AssignmentCard
|
||||||
{...a}
|
{...a}
|
||||||
users={users}
|
users={users}
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
onClick={
|
onClick={
|
||||||
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "")
|
mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
|
||||||
? () => router.push(`/assignments/creator/${a.id}`)
|
? () => router.push(`/assignments/creator/${a.id}`)
|
||||||
: () => router.push(`/assignments/${a.id}`)
|
: () => router.push(`/assignments/${a.id}`)
|
||||||
}
|
}
|
||||||
@@ -156,7 +258,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Past Assignments ({pastAssignments.length})
|
||||||
|
</h2>
|
||||||
<div className="w-full flex items-center gap-4">
|
<div className="w-full flex items-center gap-4">
|
||||||
{renderPast()}
|
{renderPast()}
|
||||||
{paginationPast()}
|
{paginationPast()}
|
||||||
@@ -166,18 +270,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
|
|||||||
<AssignmentCard
|
<AssignmentCard
|
||||||
{...a}
|
{...a}
|
||||||
users={users}
|
users={users}
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
key={a.id}
|
key={a.id}
|
||||||
allowDownload
|
allowDownload
|
||||||
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
|
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
|
||||||
|
a.entity || ""
|
||||||
|
)}
|
||||||
allowExcelDownload
|
allowExcelDownload
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Assignments start expired ({startExpiredAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Assignments start expired ({startExpiredAssignments.length})
|
||||||
|
</h2>
|
||||||
<div className="w-full flex items-center gap-4">
|
<div className="w-full flex items-center gap-4">
|
||||||
{renderExpired()}
|
{renderExpired()}
|
||||||
{paginationExpired()}
|
{paginationExpired()}
|
||||||
@@ -187,18 +295,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
|
|||||||
<AssignmentCard
|
<AssignmentCard
|
||||||
{...a}
|
{...a}
|
||||||
users={users}
|
users={users}
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
key={a.id}
|
key={a.id}
|
||||||
allowDownload
|
allowDownload
|
||||||
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
|
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
|
||||||
|
a.entity || ""
|
||||||
|
)}
|
||||||
allowExcelDownload
|
allowExcelDownload
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Archived Assignments ({archivedAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Archived Assignments ({archivedAssignments.length})
|
||||||
|
</h2>
|
||||||
<div className="w-full flex items-center gap-4">
|
<div className="w-full flex items-center gap-4">
|
||||||
{renderArchived()}
|
{renderArchived()}
|
||||||
{paginationArchived()}
|
{paginationArchived()}
|
||||||
@@ -210,7 +322,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
|
|||||||
users={users}
|
users={users}
|
||||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
key={a.id}
|
key={a.id}
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
allowDownload
|
allowDownload
|
||||||
allowUnarchive
|
allowUnarchive
|
||||||
allowExcelDownload
|
allowExcelDownload
|
||||||
|
|||||||
@@ -18,47 +18,93 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize, last } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Divider } from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX } from "react-icons/bs";
|
import {
|
||||||
|
BsBuilding,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClockFill,
|
||||||
|
BsEnvelopeFill,
|
||||||
|
BsFillPersonVcardFill,
|
||||||
|
BsPlus,
|
||||||
|
BsStopwatchFill,
|
||||||
|
BsTag,
|
||||||
|
BsTrash,
|
||||||
|
BsX,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast, ToastContainer } from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
const user = await requestUser(req, res)
|
async ({ req, res, params }) => {
|
||||||
if (!user) return redirect("/login")
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const { id } = params as { id: string };
|
const { id } = params as { id: string };
|
||||||
|
|
||||||
const group = await getGroup(id);
|
const group = await getGroup(id);
|
||||||
if (!group || !group.entity) return redirect("/classrooms")
|
if (!group || !group.entity) return redirect("/classrooms");
|
||||||
|
|
||||||
const entity = await getEntityWithRoles(group.entity)
|
const entity = await getEntityWithRoles(group.entity);
|
||||||
if (!entity) return redirect("/classrooms")
|
if (!entity) return redirect("/classrooms");
|
||||||
|
|
||||||
const canView = doesEntityAllow(user, entity, "view_classrooms")
|
const canView = doesEntityAllow(user, entity, "view_classrooms");
|
||||||
if (!canView) return redirect("/")
|
if (!canView) return redirect("/");
|
||||||
|
const [linkedUsers, users] = await Promise.all([
|
||||||
|
getEntityUsers(
|
||||||
|
entity.id,
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
corporateInformation: 1,
|
||||||
|
type: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
getSpecificUsers([...group.participants, group.admin], {
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
corporateInformation: 1,
|
||||||
|
type: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const linkedUsers = await getEntityUsers(entity.id)
|
|
||||||
const users = await getSpecificUsers([...group.participants, group.admin]);
|
|
||||||
const groupWithUser = convertToUsers(group, users);
|
const groupWithUser = convertToUsers(group, users);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }),
|
props: serialize({
|
||||||
|
user,
|
||||||
|
group: groupWithUser,
|
||||||
|
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
|
||||||
|
entity,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
},
|
||||||
|
sessionOptions
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
group: GroupWithUsers;
|
group: GroupWithUsers;
|
||||||
users: User[];
|
users: User[];
|
||||||
entity: EntityWithRoles
|
entity: EntityWithRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ user, group, users, entity }: Props) {
|
export default function Home({ user, group, users, entity }: Props) {
|
||||||
@@ -66,36 +112,88 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
|
||||||
const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom")
|
const canAddParticipants = useEntityPermission(
|
||||||
const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom")
|
user,
|
||||||
const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms")
|
entity,
|
||||||
const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom")
|
"add_to_classroom"
|
||||||
|
);
|
||||||
|
const canRemoveParticipants = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"remove_from_classroom"
|
||||||
|
);
|
||||||
|
const canRenameClassroom = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"rename_classrooms"
|
||||||
|
);
|
||||||
|
const canDeleteClassroom = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"delete_classroom"
|
||||||
|
);
|
||||||
|
|
||||||
const nonParticipantUsers = useMemo(
|
const nonParticipantUsers = useMemo(
|
||||||
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)),
|
() =>
|
||||||
[users, group.participants, group.admin.id, user.id],
|
users.filter(
|
||||||
|
(x) =>
|
||||||
|
![
|
||||||
|
...group.participants.map((g) => g.id),
|
||||||
|
group.admin.id,
|
||||||
|
user.id,
|
||||||
|
].includes(x.id)
|
||||||
|
),
|
||||||
|
[users, group.participants, group.admin.id, user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { rows, renderSearch } = useListSearch<User>(
|
const { rows, renderSearch } = useListSearch<User>(
|
||||||
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||||
isAdding ? nonParticipantUsers : group.participants,
|
isAdding ? nonParticipantUsers : group.participants
|
||||||
);
|
);
|
||||||
const { items, renderMinimal } = usePagination<User>(rows, 20);
|
const { items, renderMinimal } = usePagination<User>(rows, 20);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAllUsersInList = () =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.length === rows.length
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
...prev,
|
||||||
|
...items.reduce((acc, i) => {
|
||||||
|
if (!prev.find((item) => item === i.id)) {
|
||||||
|
(acc as string[]).push(i.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as string[]),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const removeParticipants = () => {
|
const removeParticipants = () => {
|
||||||
if (selectedUsers.length === 0) return;
|
if (selectedUsers.length === 0) return;
|
||||||
if (!canRemoveParticipants) return;
|
if (!canRemoveParticipants) return;
|
||||||
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`))
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to remove ${selectedUsers.length} participant${
|
||||||
|
selectedUsers.length === 1 ? "" : "s"
|
||||||
|
} from this group?`
|
||||||
|
)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) })
|
.patch(`/api/groups/${group.id}`, {
|
||||||
|
participants: group.participants
|
||||||
|
.map((x) => x.id)
|
||||||
|
.filter((x) => !selectedUsers.includes(x)),
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The group has been updated successfully!");
|
toast.success("The group has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -110,13 +208,24 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
const addParticipants = () => {
|
const addParticipants = () => {
|
||||||
if (selectedUsers.length === 0) return;
|
if (selectedUsers.length === 0) return;
|
||||||
if (!canAddParticipants || !isAdding) return;
|
if (!canAddParticipants || !isAdding) return;
|
||||||
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`))
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to add ${selectedUsers.length} participant${
|
||||||
|
selectedUsers.length === 1 ? "" : "s"
|
||||||
|
} to this group?`
|
||||||
|
)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] })
|
.patch(`/api/groups/${group.id}`, {
|
||||||
|
participants: [
|
||||||
|
...group.participants.map((x) => x.id),
|
||||||
|
...selectedUsers,
|
||||||
|
],
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The group has been updated successfully!");
|
toast.success("The group has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -189,7 +298,8 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/classrooms"
|
href="/classrooms"
|
||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">{group.name}</h2>
|
<h2 className="font-bold text-2xl">{group.name}</h2>
|
||||||
@@ -200,14 +310,16 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={renameGroup}
|
onClick={renameGroup}
|
||||||
disabled={isLoading || !canRenameClassroom}
|
disabled={isLoading || !canRenameClassroom}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsTag />
|
<BsTag />
|
||||||
<span className="text-xs">Rename Classroom</span>
|
<span className="text-xs">Rename Classroom</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={deleteGroup}
|
onClick={deleteGroup}
|
||||||
disabled={isLoading || !canDeleteClassroom}
|
disabled={isLoading || !canDeleteClassroom}
|
||||||
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsTrash />
|
<BsTrash />
|
||||||
<span className="text-xs">Delete Classroom</span>
|
<span className="text-xs">Delete Classroom</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -219,7 +331,8 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
<BsBuilding className="text-xl" /> {entity.label}
|
<BsBuilding className="text-xl" /> {entity.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
|
<BsFillPersonVcardFill className="text-xl" />{" "}
|
||||||
|
{getUserName(group.admin)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,14 +344,20 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsAdding(true)}
|
onClick={() => setIsAdding(true)}
|
||||||
disabled={isLoading || !canAddParticipants}
|
disabled={isLoading || !canAddParticipants}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsPlus />
|
<BsPlus />
|
||||||
<span className="text-xs">Add Participants</span>
|
<span className="text-xs">Add Participants</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={removeParticipants}
|
onClick={removeParticipants}
|
||||||
disabled={selectedUsers.length === 0 || isLoading || !canRemoveParticipants}
|
disabled={
|
||||||
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
selectedUsers.length === 0 ||
|
||||||
|
isLoading ||
|
||||||
|
!canRemoveParticipants
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsTrash />
|
<BsTrash />
|
||||||
<span className="text-xs">Remove Participants</span>
|
<span className="text-xs">Remove Participants</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -249,14 +368,20 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsAdding(false)}
|
onClick={() => setIsAdding(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsX />
|
<BsX />
|
||||||
<span className="text-xs">Discard Selection</span>
|
<span className="text-xs">Discard Selection</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={addParticipants}
|
onClick={addParticipants}
|
||||||
disabled={selectedUsers.length === 0 || isLoading || !canAddParticipants}
|
disabled={
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
selectedUsers.length === 0 ||
|
||||||
|
isLoading ||
|
||||||
|
!canAddParticipants
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsPlus />
|
<BsPlus />
|
||||||
<span className="text-xs">Add Participants</span>
|
<span className="text-xs">Add Participants</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -268,29 +393,75 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
{renderMinimal()}
|
{renderMinimal()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
{['student', 'teacher', 'corporate'].map((type) => (
|
{["student", "teacher", "corporate"].map((type) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id')
|
const typeUsers = mapBy(
|
||||||
|
filterBy(
|
||||||
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
|
"type",
|
||||||
|
type
|
||||||
|
),
|
||||||
|
"id"
|
||||||
|
);
|
||||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||||
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
|
setSelectedUsers((prev) =>
|
||||||
|
prev.filter((a) => !typeUsers.includes(a))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
|
setSelectedUsers((prev) => [
|
||||||
|
...prev.filter((a) => !typeUsers.includes(a)),
|
||||||
|
...typeUsers,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0}
|
disabled={
|
||||||
|
filterBy(
|
||||||
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
|
"type",
|
||||||
|
type
|
||||||
|
).length === 0
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
||||||
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length > 0 &&
|
filterBy(
|
||||||
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
"!bg-mti-purple-light !text-white",
|
"type",
|
||||||
)}>
|
type
|
||||||
|
).length > 0 &&
|
||||||
|
filterBy(
|
||||||
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
|
"type",
|
||||||
|
type
|
||||||
|
).every((u) => selectedUsers.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{capitalize(type)}
|
{capitalize(type)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toggleAllUsersInList();
|
||||||
|
}}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
||||||
|
(isAdding ? nonParticipantUsers : group.participants)
|
||||||
|
.length === selectedUsers.length &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{"De/Select All"}
|
||||||
|
</button>
|
||||||
|
<span className="opacity-80">
|
||||||
|
{selectedUsers.length} selected
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -298,20 +469,25 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
{items.map((u) => (
|
{items.map((u) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleUser(u)}
|
onClick={() => toggleUser(u)}
|
||||||
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants}
|
disabled={
|
||||||
|
isAdding ? !canAddParticipants : !canRemoveParticipants
|
||||||
|
}
|
||||||
key={u.id}
|
key={u.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
||||||
"hover:border-mti-purple transition ease-in-out duration-300",
|
"hover:border-mti-purple transition ease-in-out duration-300",
|
||||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
||||||
<img src={u.profilePicture} alt={u.name} />
|
<img src={u.profilePicture} alt={u.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-semibold">{getUserName(u)}</span>
|
<span className="font-semibold">{getUserName(u)}</span>
|
||||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
<span className="opacity-80 text-sm">
|
||||||
|
{USER_TYPE_LABELS[u.type]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -326,13 +502,19 @@ export default function Home({ user, group, users, entity }: Props) {
|
|||||||
<Tooltip tooltip="Expiration Date">
|
<Tooltip tooltip="Expiration Date">
|
||||||
<BsStopwatchFill />
|
<BsStopwatchFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
{u.subscriptionExpirationDate
|
||||||
|
? moment(u.subscriptionExpirationDate).format(
|
||||||
|
"DD/MM/YYYY"
|
||||||
|
)
|
||||||
|
: "Unlimited"}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="Last Login">
|
<Tooltip tooltip="Last Login">
|
||||||
<BsClockFill />
|
<BsClockFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
{u.lastLogin
|
||||||
|
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||||
|
: "N/A"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -22,24 +22,57 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Divider } from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs";
|
import {
|
||||||
|
BsCheck,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClockFill,
|
||||||
|
BsEnvelopeFill,
|
||||||
|
BsStopwatchFill,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast, ToastContainer } from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { findAllowedEntities } from "@/utils/permissions";
|
import { findAllowedEntities } from "@/utils/permissions";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : mapBy(user.entities, "id"));
|
const entities = await getEntitiesWithRoles(
|
||||||
const users = await getEntitiesUsers(mapBy(entities, 'id'))
|
isAdmin(user) ? undefined : mapBy(user.entities, "id")
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "create_classroom")
|
);
|
||||||
|
const users = await getEntitiesUsers(
|
||||||
|
mapBy(entities, "id"),
|
||||||
|
{
|
||||||
|
id: { $ne: user.id },
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
type: 1,
|
||||||
|
corporateInformation: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const allowedEntities = findAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"create_classroom"
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}),
|
props: serialize({
|
||||||
|
user,
|
||||||
|
entities: allowedEntities,
|
||||||
|
users: users,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -55,27 +88,48 @@ export default function Home({user, users, entities}: Props) {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
|
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
|
||||||
|
|
||||||
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users])
|
const entityUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
!entity
|
||||||
|
? users
|
||||||
|
: users.filter((u) => mapBy(u.entities, "id").includes(entity)),
|
||||||
|
[entity, users]
|
||||||
|
);
|
||||||
|
|
||||||
const { rows, renderSearch } = useListSearch<User>(
|
const { rows, renderSearch } = useListSearch<User>(
|
||||||
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers
|
[
|
||||||
|
["name"],
|
||||||
|
["type"],
|
||||||
|
["corporateInformation", "companyInformation", "name"],
|
||||||
|
],
|
||||||
|
entityUsers
|
||||||
);
|
);
|
||||||
|
|
||||||
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => setSelectedUsers([]), [entity])
|
useEffect(() => setSelectedUsers([]), [entity]);
|
||||||
|
|
||||||
const createGroup = () => {
|
const createGroup = () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to create this group with ${selectedUsers.length} participants?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity})
|
.post<{ id: string }>(`/api/groups`, {
|
||||||
|
name,
|
||||||
|
participants: selectedUsers,
|
||||||
|
admin: user.id,
|
||||||
|
entity,
|
||||||
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
toast.success("Your group has been created successfully!");
|
toast.success("Your group has been created successfully!");
|
||||||
router.replace(`/classrooms/${result.data.id}`);
|
router.replace(`/classrooms/${result.data.id}`);
|
||||||
@@ -87,7 +141,10 @@ export default function Home({user, users, entities}: Props) {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -107,7 +164,8 @@ export default function Home({user, users, entities}: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/classrooms"
|
href="/classrooms"
|
||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">Create Classroom</h2>
|
<h2 className="font-bold text-2xl">Create Classroom</h2>
|
||||||
@@ -116,7 +174,8 @@ export default function Home({user, users, entities}: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={createGroup}
|
onClick={createGroup}
|
||||||
disabled={!name.trim() || !entity || isLoading}
|
disabled={!name.trim() || !entity || isLoading}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsCheck />
|
<BsCheck />
|
||||||
<span className="text-xs">Create Classroom</span>
|
<span className="text-xs">Create Classroom</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -126,46 +185,67 @@ export default function Home({user, users, entities}: Props) {
|
|||||||
<div className="grid grid-cols-2 gap-4 place-items-end">
|
<div className="grid grid-cols-2 gap-4 place-items-end">
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<span className="font-semibold text-xl">Classroom Name:</span>
|
<span className="font-semibold text-xl">Classroom Name:</span>
|
||||||
<Input name="name" onChange={setName} type="text" placeholder="Classroom A" />
|
<Input
|
||||||
|
name="name"
|
||||||
|
onChange={setName}
|
||||||
|
type="text"
|
||||||
|
placeholder="Classroom A"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<span className="font-semibold text-xl">Entity:</span>
|
<span className="font-semibold text-xl">Entity:</span>
|
||||||
<Select
|
<Select
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
||||||
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}}
|
defaultValue={{
|
||||||
|
value: entities[0]?.id,
|
||||||
|
label: entities[0]?.label,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="font-semibold text-xl">Participants ({selectedUsers.length} selected):</span>
|
<span className="font-semibold text-xl">
|
||||||
|
Participants ({selectedUsers.length} selected):
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex items-center gap-4">
|
<div className="w-full flex items-center gap-4">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
{renderMinimal()}
|
{renderMinimal()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
{['student', 'teacher', 'corporate'].map((type) => (
|
{["student", "teacher", "corporate"].map((type) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id')
|
const typeUsers = mapBy(
|
||||||
|
filterBy(entityUsers, "type", type),
|
||||||
|
"id"
|
||||||
|
);
|
||||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||||
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
|
setSelectedUsers((prev) =>
|
||||||
|
prev.filter((a) => !typeUsers.includes(a))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
|
setSelectedUsers((prev) => [
|
||||||
|
...prev.filter((a) => !typeUsers.includes(a)),
|
||||||
|
...typeUsers,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={filterBy(entityUsers, 'type', type).length === 0}
|
disabled={filterBy(entityUsers, "type", type).length === 0}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
||||||
filterBy(entityUsers, 'type', type).length > 0 &&
|
filterBy(entityUsers, "type", type).length > 0 &&
|
||||||
filterBy(entityUsers, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
|
filterBy(entityUsers, "type", type).every((u) =>
|
||||||
"!bg-mti-purple-light !text-white",
|
selectedUsers.includes(u.id)
|
||||||
)}>
|
) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{capitalize(type)}
|
{capitalize(type)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -181,15 +261,18 @@ export default function Home({user, users, entities}: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
||||||
"hover:border-mti-purple transition ease-in-out duration-300",
|
"hover:border-mti-purple transition ease-in-out duration-300",
|
||||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
||||||
<img src={u.profilePicture} alt={u.name} />
|
<img src={u.profilePicture} alt={u.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-semibold">{getUserName(u)}</span>
|
<span className="font-semibold">{getUserName(u)}</span>
|
||||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
<span className="opacity-80 text-sm">
|
||||||
|
{USER_TYPE_LABELS[u.type]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,13 +287,17 @@ export default function Home({user, users, entities}: Props) {
|
|||||||
<Tooltip tooltip="Expiration Date">
|
<Tooltip tooltip="Expiration Date">
|
||||||
<BsStopwatchFill />
|
<BsStopwatchFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
{u.subscriptionExpirationDate
|
||||||
|
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
|
||||||
|
: "Unlimited"}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="Last Login">
|
<Tooltip tooltip="Last Login">
|
||||||
<BsClockFill />
|
<BsClockFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
{u.lastLogin
|
||||||
|
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||||
|
: "N/A"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,22 +27,41 @@ import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTrans
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id");
|
const entityIDS = mapBy(user.entities, "id");
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS)
|
const entities = await getEntitiesWithRoles(
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms")
|
isAdmin(user) ? undefined : entityIDS
|
||||||
|
);
|
||||||
|
|
||||||
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id'));
|
const allowedEntities = findAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"view_classrooms"
|
||||||
|
);
|
||||||
|
|
||||||
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants, g.admin])));
|
const groups = await getGroupsForEntities(mapBy(allowedEntities, "id"));
|
||||||
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
|
|
||||||
|
const users = await getSpecificUsers(
|
||||||
|
uniq(groups.flatMap((g) => [...g.participants, g.admin])),
|
||||||
|
{ _id: 0, id: 1, name: 1, email: 1, corporateInformation: 1, type: 1 }
|
||||||
|
);
|
||||||
|
const groupsWithUsers: GroupWithUsers[] = groups.map((g) =>
|
||||||
|
convertToUsers(
|
||||||
|
g,
|
||||||
|
users.filter((x) => (isAdmin(user) ? true : !isAdmin(x)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }),
|
props: serialize({
|
||||||
|
user,
|
||||||
|
groups: groupsWithUsers,
|
||||||
|
entities: allowedEntities,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -59,39 +78,60 @@ const SEARCH_FIELDS = [
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
groups: GroupWithUsers[];
|
groups: GroupWithUsers[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
}
|
}
|
||||||
export default function Home({ user, groups, entities }: Props) {
|
export default function Home({ user, groups, entities }: Props) {
|
||||||
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom');
|
const entitiesAllowCreate = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"create_classroom"
|
||||||
|
);
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
|
||||||
const renderCard = (group: GroupWithUsers) => (
|
const renderCard = (group: GroupWithUsers) => (
|
||||||
<Link
|
<Link
|
||||||
href={`/classrooms/${group.id}`}
|
href={`/classrooms/${group.id}`}
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer">
|
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Classroom</span>
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
|
Classroom
|
||||||
|
</span>
|
||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span>
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
{getUserName(group.admin)}
|
{getUserName(group.admin)}
|
||||||
</span>
|
</span>
|
||||||
{!!group.entity && (
|
{!!group.entity && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span>
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
{findBy(entities, 'id', group.entity)?.label}
|
Entity
|
||||||
|
</span>
|
||||||
|
{findBy(entities, "id", group.entity)?.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Participants</span>
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
<span className="bg-mti-purple-light/50 px-2">{group.participants.length}</span>
|
Participants
|
||||||
|
</span>
|
||||||
|
<span className="bg-mti-purple-light/50 px-2">
|
||||||
|
{group.participants.length}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{group.participants.slice(0, 3).map(getUserName).join(", ")}{' '}
|
{group.participants.slice(0, 3).map(getUserName).join(", ")}{" "}
|
||||||
{group.participants.length > 3 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {group.participants.length - 3} more</span> : ""}
|
{group.participants.length > 3 ? (
|
||||||
|
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
|
||||||
|
and {group.participants.length - 3} more
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-fit">
|
<div className="w-fit">
|
||||||
@@ -103,7 +143,8 @@ export default function Home({ user, groups, entities }: Props) {
|
|||||||
const firstCard = () => (
|
const firstCard = () => (
|
||||||
<Link
|
<Link
|
||||||
href={`/classrooms/create`}
|
href={`/classrooms/create`}
|
||||||
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
|
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
|
||||||
|
>
|
||||||
<BsPlus size={40} />
|
<BsPlus size={40} />
|
||||||
<span className="font-semibold">Create Classroom</span>
|
<span className="font-semibold">Create Classroom</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -123,24 +164,33 @@ export default function Home({ user, groups, entities }: Props) {
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-col gap-4 w-full h-full">
|
<section className="flex flex-col gap-4 w-full h-full">
|
||||||
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]">
|
<Modal
|
||||||
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
|
isOpen={showImport}
|
||||||
|
onClose={() => setShowImport(false)}
|
||||||
|
maxWidth="max-w-[85%]"
|
||||||
|
>
|
||||||
|
<StudentClassroomTransfer
|
||||||
|
user={user}
|
||||||
|
entities={entities}
|
||||||
|
onFinish={() => setShowImport(false)}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<h2 className="font-bold text-2xl">Classrooms</h2>
|
<h2 className="font-bold text-2xl">Classrooms</h2>
|
||||||
{entitiesAllowCreate.length !== 0 && <button
|
{entitiesAllowCreate.length !== 0 && (
|
||||||
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
|
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
|
||||||
"bg-mti-purple-light border border-mti-purple-light rounded-xl text-white",
|
"bg-mti-purple-light border border-mti-purple-light rounded-xl text-white",
|
||||||
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out",
|
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
onClick={() => setShowImport(true)}
|
onClick={() => setShowImport(true)}
|
||||||
>
|
>
|
||||||
<FaFileUpload className="w-5 h-5" />
|
<FaFileUpload className="w-5 h-5" />
|
||||||
Transfer Students
|
Transfer Students
|
||||||
</button>
|
</button>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,18 +6,13 @@ import { Stat, Type, User } from "@/interfaces/user";
|
|||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {
|
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
countEntitiesAssignments,
|
|
||||||
} from "@/utils/assignments.be";
|
|
||||||
import { getEntities } from "@/utils/entities.be";
|
import { getEntities } from "@/utils/entities.be";
|
||||||
import { countGroups } from "@/utils/groups.be";
|
import { countGroups } from "@/utils/groups.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import { getStatsByUsers } from "@/utils/stats.be";
|
import { getStatsByUsers } from "@/utils/stats.be";
|
||||||
import {
|
import { countUsersByTypes, getUsers } from "@/utils/users.be";
|
||||||
countUsersByTypes,
|
|
||||||
getUsers,
|
|
||||||
} from "@/utils/users.be";
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -49,50 +44,48 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
if (!user || !user.isVerified) return redirect("/login");
|
if (!user || !user.isVerified) return redirect("/login");
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
||||||
|
const [
|
||||||
const students = await getUsers(
|
entities,
|
||||||
|
usersCount,
|
||||||
|
groupsCount,
|
||||||
|
students,
|
||||||
|
latestStudents,
|
||||||
|
latestTeachers,
|
||||||
|
] = await Promise.all([
|
||||||
|
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
|
||||||
|
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
|
||||||
|
countGroups(),
|
||||||
|
getUsers(
|
||||||
{ type: "student" },
|
{ type: "student" },
|
||||||
10,
|
10,
|
||||||
{
|
{
|
||||||
averageLevel: -1,
|
averageLevel: -1,
|
||||||
},
|
},
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
);
|
),
|
||||||
|
getUsers(
|
||||||
const usersCount = await countUsersByTypes([
|
|
||||||
"student",
|
|
||||||
"teacher",
|
|
||||||
"corporate",
|
|
||||||
"mastercorporate",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const latestStudents = await getUsers(
|
|
||||||
{ type: "student" },
|
{ type: "student" },
|
||||||
10,
|
10,
|
||||||
{
|
{
|
||||||
registrationDate: -1,
|
registrationDate: -1,
|
||||||
},
|
},
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
);
|
),
|
||||||
const latestTeachers = await getUsers(
|
getUsers(
|
||||||
{ type: "teacher" },
|
{ type: "teacher" },
|
||||||
10,
|
10,
|
||||||
{
|
{
|
||||||
registrationDate: -1,
|
registrationDate: -1,
|
||||||
},
|
},
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
);
|
),
|
||||||
|
]);
|
||||||
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
|
const [assignmentsCount, stats] = await Promise.all([
|
||||||
|
countEntitiesAssignments(mapBy(entities, "id"), {
|
||||||
const assignmentsCount = await countEntitiesAssignments(
|
archived: { $ne: true },
|
||||||
mapBy(entities, "id"),
|
}),
|
||||||
{ archived: { $ne: true } }
|
getStatsByUsers(mapBy(students, "id")),
|
||||||
);
|
]);
|
||||||
|
|
||||||
const groupsCount = await countGroups();
|
|
||||||
|
|
||||||
const stats = await getStatsByUsers(mapBy(students, "id"));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import {
|
|||||||
groupAllowedEntitiesByPermissions,
|
groupAllowedEntitiesByPermissions,
|
||||||
} from "@/utils/permissions";
|
} from "@/utils/permissions";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import {
|
import { countAllowedUsers, getUsers } from "@/utils/users.be";
|
||||||
countAllowedUsers,
|
|
||||||
getUsers,
|
|
||||||
} from "@/utils/users.be";
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
@@ -71,37 +68,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
||||||
const entitiesIDS = mapBy(entities, "id") || [];
|
const entitiesIDS = mapBy(entities, "id") || [];
|
||||||
|
|
||||||
|
const [
|
||||||
const students = await getUsers(
|
students,
|
||||||
|
latestStudents,
|
||||||
|
latestTeachers,
|
||||||
|
userCounts,
|
||||||
|
assignmentsCount,
|
||||||
|
groupsCount,
|
||||||
|
] = await Promise.all([
|
||||||
|
getUsers(
|
||||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||||
10,
|
10,
|
||||||
{ averageLevel: -1 },
|
{ averageLevel: -1 },
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
);
|
),
|
||||||
const latestStudents = await getUsers(
|
getUsers(
|
||||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||||
10,
|
10,
|
||||||
{ registrationDate: -1 },
|
{ registrationDate: -1 },
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
);
|
),
|
||||||
const latestTeachers = await getUsers(
|
getUsers(
|
||||||
{
|
{
|
||||||
type: "teacher",
|
type: "teacher",
|
||||||
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
|
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
|
||||||
},
|
},
|
||||||
10,
|
10,
|
||||||
{ registrationDate: -1 },
|
{ registrationDate: -1 },
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
);
|
),
|
||||||
|
countAllowedUsers(user, entities),
|
||||||
const userCounts = await countAllowedUsers(user, entities);
|
countEntitiesAssignments(entitiesIDS, {
|
||||||
|
archived: { $ne: true },
|
||||||
const assignmentsCount = await countEntitiesAssignments(
|
}),
|
||||||
entitiesIDS,
|
countGroupsByEntities(entitiesIDS),
|
||||||
{ archived: { $ne: true } }
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
const groupsCount = await countGroupsByEntities(entitiesIDS);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
|
|||||||
@@ -6,17 +6,12 @@ import { Stat, Type, User } from "@/interfaces/user";
|
|||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {
|
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
countEntitiesAssignments,
|
|
||||||
} from "@/utils/assignments.be";
|
|
||||||
import { getEntities } from "@/utils/entities.be";
|
import { getEntities } from "@/utils/entities.be";
|
||||||
import { countGroups } from "@/utils/groups.be";
|
import { countGroups } from "@/utils/groups.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import {
|
import { countUsersByTypes, getUsers } from "@/utils/users.be";
|
||||||
countUsersByTypes,
|
|
||||||
getUsers,
|
|
||||||
} from "@/utils/users.be";
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -49,45 +44,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
||||||
|
|
||||||
const students = await getUsers(
|
const [
|
||||||
|
students,
|
||||||
|
latestStudents,
|
||||||
|
latestTeachers,
|
||||||
|
usersCount,
|
||||||
|
entities,
|
||||||
|
groupsCount,
|
||||||
|
] = await Promise.all([
|
||||||
|
getUsers(
|
||||||
{ type: "student" },
|
{ type: "student" },
|
||||||
10,
|
10,
|
||||||
{
|
{ averageLevel: -1 },
|
||||||
averageLevel: -1,
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
},
|
),
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
getUsers(
|
||||||
);
|
|
||||||
|
|
||||||
const usersCount = await countUsersByTypes([
|
|
||||||
"student",
|
|
||||||
"teacher",
|
|
||||||
"corporate",
|
|
||||||
"mastercorporate",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const latestStudents = await getUsers(
|
|
||||||
{ type: "student" },
|
{ type: "student" },
|
||||||
10,
|
10,
|
||||||
{
|
{ registrationDate: -1 },
|
||||||
registrationDate: -1,
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
},
|
),
|
||||||
{id:1, name: 1, email: 1, profilePicture: 1 }
|
getUsers(
|
||||||
);
|
|
||||||
const latestTeachers = await getUsers(
|
|
||||||
{ type: "teacher" },
|
{ type: "teacher" },
|
||||||
10,
|
10,
|
||||||
{
|
{ registrationDate: -1 },
|
||||||
registrationDate: -1,
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
},
|
),
|
||||||
{ id:1,name: 1, email: 1, profilePicture: 1 }
|
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
|
||||||
);
|
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
|
||||||
|
countGroups(),
|
||||||
|
]);
|
||||||
|
|
||||||
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
|
|
||||||
const assignmentsCount = await countEntitiesAssignments(
|
const assignmentsCount = await countEntitiesAssignments(
|
||||||
mapBy(entities, "id"),
|
mapBy(entities, "id"),
|
||||||
{ archived: { $ne: true } }
|
{ archived: { $ne: true } }
|
||||||
);
|
);
|
||||||
const groupsCount = await countGroups();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { User } from "@/interfaces/user";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { redirect } from "@/utils";
|
import { redirect } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import { requestUser } from "@/utils/api";
|
|||||||
import {countEntitiesAssignments} from "@/utils/assignments.be";
|
import {countEntitiesAssignments} from "@/utils/assignments.be";
|
||||||
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
||||||
import {countGroupsByEntities} from "@/utils/groups.be";
|
import {countGroupsByEntities} from "@/utils/groups.be";
|
||||||
import {
|
import {checkAccess, groupAllowedEntitiesByPermissions} from "@/utils/permissions";
|
||||||
checkAccess,
|
|
||||||
groupAllowedEntitiesByPermissions,
|
|
||||||
} from "@/utils/permissions";
|
|
||||||
import {groupByExam} from "@/utils/stats";
|
import {groupByExam} from "@/utils/stats";
|
||||||
import {countAllowedUsers, getUsers} from "@/utils/users.be";
|
import {countAllowedUsers, getUsers} from "@/utils/users.be";
|
||||||
import {clsx} from "clsx";
|
import {clsx} from "clsx";
|
||||||
@@ -22,16 +19,7 @@ import moment from "moment";
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useMemo} from "react";
|
import {useMemo} from "react";
|
||||||
import {
|
import {BsBank, BsClock, BsEnvelopePaper, BsPencilSquare, BsPeople, BsPeopleFill, BsPersonFill, BsPersonFillGear} from "react-icons/bs";
|
||||||
BsBank,
|
|
||||||
BsClock,
|
|
||||||
BsEnvelopePaper,
|
|
||||||
BsPencilSquare,
|
|
||||||
BsPeople,
|
|
||||||
BsPeopleFill,
|
|
||||||
BsPersonFill,
|
|
||||||
BsPersonFillGear,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import {ToastContainer} from "react-toastify";
|
import {ToastContainer} from "react-toastify";
|
||||||
import {isAdmin} from "@/utils/users";
|
import {isAdmin} from "@/utils/users";
|
||||||
|
|
||||||
@@ -51,17 +39,11 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
const user = await requestUser(req, res);
|
const user = await requestUser(req, res);
|
||||||
if (!user || !user.isVerified) return redirect("/login");
|
if (!user || !user.isVerified) return redirect("/login");
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
|
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/");
|
||||||
return redirect("/");
|
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
const entities = await getEntitiesWithRoles(
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||||
isAdmin(user) ? undefined : entityIDS
|
const {["view_students"]: allowedStudentEntities, ["view_teachers"]: allowedTeacherEntities} = groupAllowedEntitiesByPermissions(user, entities, [
|
||||||
);
|
|
||||||
const {
|
|
||||||
["view_students"]: allowedStudentEntities,
|
|
||||||
["view_teachers"]: allowedTeacherEntities,
|
|
||||||
} = groupAllowedEntitiesByPermissions(user, entities, [
|
|
||||||
"view_students",
|
"view_students",
|
||||||
"view_teachers",
|
"view_teachers",
|
||||||
]);
|
]);
|
||||||
@@ -70,37 +52,32 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
|
|
||||||
const entitiesIDS = mapBy(entities, "id") || [];
|
const entitiesIDS = mapBy(entities, "id") || [];
|
||||||
|
|
||||||
const students = await getUsers(
|
const [students, latestStudents, latestTeachers, userCounts, assignmentsCount, groupsCount] = await Promise.all([
|
||||||
|
getUsers(
|
||||||
{type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
|
{type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
|
||||||
10,
|
10,
|
||||||
{averageLevel: -1},
|
{averageLevel: -1},
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
|
||||||
);
|
),
|
||||||
|
getUsers(
|
||||||
const latestStudents = await getUsers(
|
|
||||||
{type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
|
{type: "student", "entities.id": {$in: allowedStudentEntitiesIDS}},
|
||||||
10,
|
10,
|
||||||
{registrationDate: -1},
|
{registrationDate: -1},
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
|
||||||
);
|
),
|
||||||
|
getUsers(
|
||||||
const latestTeachers = await getUsers(
|
|
||||||
{
|
{
|
||||||
type: "teacher",
|
type: "teacher",
|
||||||
"entities.id": {$in: mapBy(allowedTeacherEntities, "id")},
|
"entities.id": {$in: mapBy(allowedTeacherEntities, "id")},
|
||||||
},
|
},
|
||||||
10,
|
10,
|
||||||
{registrationDate: -1},
|
{registrationDate: -1},
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{_id: 0, id: 1, name: 1, email: 1, profilePicture: 1},
|
||||||
);
|
),
|
||||||
|
countAllowedUsers(user, entities),
|
||||||
const userCounts = await countAllowedUsers(user, entities);
|
countEntitiesAssignments(entitiesIDS, {archived: {$ne: true}}),
|
||||||
|
countGroupsByEntities(entitiesIDS),
|
||||||
const assignmentsCount = await countEntitiesAssignments(entitiesIDS, {
|
]);
|
||||||
archived: { $ne: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupsCount = await countGroupsByEntities(entitiesIDS);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
@@ -127,36 +104,14 @@ export default function Dashboard({
|
|||||||
stats = [],
|
stats = [],
|
||||||
groupsCount,
|
groupsCount,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const totalCount = useMemo(
|
const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]);
|
||||||
() =>
|
|
||||||
userCounts.corporate +
|
|
||||||
userCounts.mastercorporate +
|
|
||||||
userCounts.student +
|
|
||||||
userCounts.teacher,
|
|
||||||
[userCounts]
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalLicenses = useMemo(
|
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]);
|
||||||
() =>
|
|
||||||
entities.reduce(
|
|
||||||
(acc, curr) => acc + parseInt(curr.licenses.toString()),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
[entities]
|
|
||||||
);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const allowedEntityStatistics = useAllowedEntities(
|
const allowedEntityStatistics = useAllowedEntities(user, entities, "view_entity_statistics");
|
||||||
user,
|
const allowedStudentPerformance = useAllowedEntities(user, entities, "view_student_performance");
|
||||||
entities,
|
|
||||||
"view_entity_statistics"
|
|
||||||
);
|
|
||||||
const allowedStudentPerformance = useAllowedEntities(
|
|
||||||
user,
|
|
||||||
entities,
|
|
||||||
"view_student_performance"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -194,12 +149,13 @@ export default function Dashboard({
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPeople}
|
Icon={BsBank}
|
||||||
onClick={() => router.push("/classrooms")}
|
onClick={() => router.push("/users?type=mastercorporate")}
|
||||||
label="Classrooms"
|
label="Master Corporates"
|
||||||
value={groupsCount}
|
value={userCounts.mastercorporate}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
<IconCard Icon={BsPeople} onClick={() => router.push("/classrooms")} label="Classrooms" value={groupsCount} color="purple" />
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPeopleFill}
|
Icon={BsPeopleFill}
|
||||||
onClick={() => router.push("/entities")}
|
onClick={() => router.push("/entities")}
|
||||||
@@ -230,19 +186,13 @@ export default function Dashboard({
|
|||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
label="Assignments"
|
label="Assignments"
|
||||||
value={assignmentsCount}
|
value={assignmentsCount}
|
||||||
className={clsx(
|
className={clsx(allowedEntityStatistics.length === 0 && "col-span-2")}
|
||||||
allowedEntityStatistics.length === 0 && "col-span-2"
|
|
||||||
)}
|
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsClock}
|
Icon={BsClock}
|
||||||
label="Expiration Date"
|
label="Expiration Date"
|
||||||
value={
|
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||||
user.subscriptionExpirationDate
|
|
||||||
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
|
|
||||||
: "Unlimited"
|
|
||||||
}
|
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -255,7 +205,7 @@ export default function Dashboard({
|
|||||||
users={students.sort(
|
users={students.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
|
||||||
Object.keys(groupByExam(filterBy(stats, "user", a))).length
|
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
|
||||||
)}
|
)}
|
||||||
title="Highest exam count students"
|
title="Highest exam count students"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
|
|||||||
import ModuleBadge from "@/components/ModuleBadge";
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import { Grading } from "@/interfaces";
|
import { Grading, Module } from "@/interfaces";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { InviteWithEntity } from "@/interfaces/invite";
|
import { InviteWithEntity } from "@/interfaces/invite";
|
||||||
@@ -16,7 +16,6 @@ import useExamStore from "@/stores/exam";
|
|||||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getAssignmentsForStudent } from "@/utils/assignments.be";
|
import { getAssignmentsForStudent } from "@/utils/assignments.be";
|
||||||
import { getEntities } from "@/utils/entities.be";
|
|
||||||
import { getExamsByIds } from "@/utils/exams.be";
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +33,7 @@ import { capitalize, uniqBy } from "lodash";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
BsBook,
|
BsBook,
|
||||||
BsClipboard,
|
BsClipboard,
|
||||||
@@ -65,44 +65,49 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
return redirect("/");
|
return redirect("/");
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
|
|
||||||
const entities = await getEntities(entityIDS, { _id: 0, label: 1 });
|
|
||||||
const currentDate = moment().toISOString();
|
const currentDate = moment().toISOString();
|
||||||
const assignments = await getAssignmentsForStudent(user.id, currentDate);
|
|
||||||
const stats = await getDetailedStatsByUser(user.id, "stats");
|
|
||||||
|
|
||||||
const assignmentsIDs = mapBy(assignments, "id");
|
const [assignments, stats, invites, grading] = await Promise.all([
|
||||||
|
getAssignmentsForStudent(user.id, currentDate),
|
||||||
const sessions = await getSessionsByUser(user.id, 10, {
|
getDetailedStatsByUser(user.id, "stats"),
|
||||||
["assignment.id"]: { $in: assignmentsIDs },
|
getInvitesByInvitee(user.id),
|
||||||
});
|
getGradingSystemByEntity(entityIDS[0] || "", {
|
||||||
const invites = await getInvitesByInvitee(user.id);
|
|
||||||
const grading = await getGradingSystemByEntity(entityIDS[0] || "", {
|
|
||||||
_id: 0,
|
_id: 0,
|
||||||
steps: 1,
|
steps: 1,
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
const assignmentsIDs = mapBy(assignments, "id");
|
||||||
|
const [sessions, ...formattedInvites] = await Promise.all([
|
||||||
|
getSessionsByUser(user.id, 10, {
|
||||||
|
["assignment.id"]: { $in: assignmentsIDs },
|
||||||
|
}),
|
||||||
|
...invites.map(convertInvitersToEntity),
|
||||||
|
]);
|
||||||
|
|
||||||
const formattedInvites = await Promise.all(
|
|
||||||
invites.map(convertInvitersToEntity)
|
|
||||||
);
|
|
||||||
const examIDs = uniqBy(
|
const examIDs = uniqBy(
|
||||||
assignments.flatMap((a) =>
|
assignments.reduce<{ module: Module; id: string; key: string }[]>(
|
||||||
a.exams.map((e: { module: string; id: string }) => ({
|
(acc, a) => {
|
||||||
|
a.exams.forEach((e: { module: Module; id: string }) => {
|
||||||
|
acc.push({
|
||||||
module: e.module,
|
module: e.module,
|
||||||
id: e.id,
|
id: e.id,
|
||||||
key: `${e.module}_${e.id}`,
|
key: `${e.module}_${e.id}`,
|
||||||
}))
|
});
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[]
|
||||||
),
|
),
|
||||||
"key"
|
"key"
|
||||||
);
|
);
|
||||||
|
|
||||||
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
|
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
user,
|
user,
|
||||||
entities,
|
|
||||||
assignments,
|
assignments,
|
||||||
stats,
|
stats: stats ,
|
||||||
exams,
|
exams,
|
||||||
sessions,
|
sessions,
|
||||||
invites: formattedInvites,
|
invites: formattedInvites,
|
||||||
@@ -145,6 +150,11 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const entitiesLabels = useMemo(
|
||||||
|
() => (entities.length > 0 ? mapBy(entities, "label")?.join(", ") : ""),
|
||||||
|
[entities]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -160,7 +170,7 @@ export default function Dashboard({
|
|||||||
<>
|
<>
|
||||||
{entities.length > 0 && (
|
{entities.length > 0 && (
|
||||||
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
|
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
|
||||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
<b>{entitiesLabels}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -171,7 +181,7 @@ export default function Dashboard({
|
|||||||
icon: (
|
icon: (
|
||||||
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
||||||
),
|
),
|
||||||
value: stats.fullExams,
|
value: stats?.fullExams || 0,
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
tooltip: "Number of all conducted completed exams",
|
tooltip: "Number of all conducted completed exams",
|
||||||
},
|
},
|
||||||
@@ -179,7 +189,7 @@ export default function Dashboard({
|
|||||||
icon: (
|
icon: (
|
||||||
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
||||||
),
|
),
|
||||||
value: stats.uniqueModules,
|
value: stats?.uniqueModules || 0,
|
||||||
label: "Modules",
|
label: "Modules",
|
||||||
tooltip:
|
tooltip:
|
||||||
"Number of all exam modules performed including Level Test",
|
"Number of all exam modules performed including Level Test",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { requestUser } from "@/utils/api";
|
|||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
import { getEntitiesUsers } from "@/utils/users.be";
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
import { isAdmin } from "@/utils/users";
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -52,7 +53,8 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
|
|
||||||
const filteredEntities = findAllowedEntities(user, entities, "view_students");
|
const filteredEntities = findAllowedEntities(user, entities, "view_students");
|
||||||
|
|
||||||
const students = await getEntitiesUsers(
|
const [students, assignments, groups] = await Promise.all([
|
||||||
|
getEntitiesUsers(
|
||||||
mapBy(filteredEntities, "id"),
|
mapBy(filteredEntities, "id"),
|
||||||
{
|
{
|
||||||
type: "student",
|
type: "student",
|
||||||
@@ -67,14 +69,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
levels: 1,
|
levels: 1,
|
||||||
registrationDate: 1,
|
registrationDate: 1,
|
||||||
}
|
}
|
||||||
);
|
),
|
||||||
|
getEntitiesAssignments(entityIDS),
|
||||||
const assignments = await getEntitiesAssignments(entityIDS);
|
getGroupsByEntities(entityIDS),
|
||||||
|
]);
|
||||||
|
|
||||||
const stats = await getStatsByUsers(students.map((u) => u.id));
|
const stats = await getStatsByUsers(students.map((u) => u.id));
|
||||||
|
|
||||||
const groups = await getGroupsByEntities(entityIDS);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, students, entities, assignments, stats, groups }),
|
props: serialize({ user, students, entities, assignments, stats, groups }),
|
||||||
};
|
};
|
||||||
@@ -100,6 +101,10 @@ export default function Dashboard({
|
|||||||
entities,
|
entities,
|
||||||
"view_student_performance"
|
"view_student_performance"
|
||||||
);
|
);
|
||||||
|
const entitiesLabels = useMemo(
|
||||||
|
() => mapBy(entities, "label")?.join(", "),
|
||||||
|
[entities]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -117,7 +122,7 @@ export default function Dashboard({
|
|||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4">
|
||||||
{entities.length > 0 && (
|
{entities.length > 0 && (
|
||||||
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
<b>{entitiesLabels}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import { getEntityWithRoles } from "@/utils/entities.be";
|
|||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import { doesEntityAllow } from "@/utils/permissions";
|
import { doesEntityAllow } from "@/utils/permissions";
|
||||||
import { getUserName, isAdmin } from "@/utils/users";
|
import { getUserName, isAdmin } from "@/utils/users";
|
||||||
import { filterAllowedUsers, getEntitiesUsers, getEntityUsers, getUsers } from "@/utils/users.be";
|
import {
|
||||||
|
filterAllowedUsers,
|
||||||
|
getEntitiesUsers,
|
||||||
|
getEntityUsers,
|
||||||
|
getUsers,
|
||||||
|
} from "@/utils/users.be";
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -42,14 +47,18 @@ import {
|
|||||||
BsX,
|
BsX,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import entities from "../../api/entities";
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
|
if (today.add(1, "days").isAfter(momentDate))
|
||||||
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
return "!bg-mti-red-ultralight border-mti-red-light";
|
||||||
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
if (today.add(3, "days").isAfter(momentDate))
|
||||||
|
return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||||
|
if (today.add(7, "days").isAfter(momentDate))
|
||||||
|
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
};
|
};
|
||||||
|
|
||||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
|
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
|
||||||
@@ -57,26 +66,62 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
|
|||||||
label,
|
label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
|
const USER_DATA_SCHEMA = {
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
type: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
email: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
entities: 1,
|
||||||
|
corporateInformation: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
|
async ({ req, params }) => {
|
||||||
const user = req.session.user as User;
|
const user = req.session.user as User;
|
||||||
|
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const { id } = params as { id: string };
|
const { id } = params as { id: string };
|
||||||
|
|
||||||
const entity = await getEntityWithRoles(id);
|
const entity = await getEntityWithRoles(id);
|
||||||
if (!entity) return redirect("/entities")
|
if (!entity) return redirect("/entities");
|
||||||
|
|
||||||
if (!doesEntityAllow(user, entity, "view_entities")) return redirect(`/entities`)
|
if (!doesEntityAllow(user, entity, "view_entities"))
|
||||||
|
return redirect(`/entities`);
|
||||||
const linkedUsers = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(user.entities, 'id'),
|
const [linkedUsers, entityUsers] = await Promise.all([
|
||||||
{ $and: [{ type: { $ne: "developer" } }, { type: { $ne: "admin" } }] }))
|
isAdmin(user)
|
||||||
const entityUsers = await (isAdmin(user) ? getEntityUsers(id) : filterAllowedUsers(user, [entity]));
|
? getUsers({}, 0, {}, USER_DATA_SCHEMA)
|
||||||
|
: getEntitiesUsers(
|
||||||
|
mapBy(user.entities, "id"),
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{ type: { $ne: "developer" } },
|
||||||
|
{ type: { $ne: "admin" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
USER_DATA_SCHEMA
|
||||||
|
),
|
||||||
|
isAdmin(user)
|
||||||
|
? getEntityUsers(
|
||||||
|
id,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
id: { $ne: user.id },
|
||||||
|
},
|
||||||
|
USER_DATA_SCHEMA
|
||||||
|
)
|
||||||
|
: filterAllowedUsers(user, [entity], USER_DATA_SCHEMA),
|
||||||
|
]);
|
||||||
|
|
||||||
const usersWithRole = entityUsers.map((u) => {
|
const usersWithRole = entityUsers.map((u) => {
|
||||||
const e = u.entities.find((e) => e.id === id);
|
const e = u?.entities?.find((e) => e.id === id);
|
||||||
return { ...u, role: findBy(entity.roles, 'id', e?.role) };
|
return { ...u, role: findBy(entity.roles, "id", e?.role) };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,10 +129,14 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, params }) =>
|
|||||||
user,
|
user,
|
||||||
entity,
|
entity,
|
||||||
users: usersWithRole,
|
users: usersWithRole,
|
||||||
linkedUsers: linkedUsers.filter(x => x.id !== user.id && !mapBy(entityUsers, 'id').includes(x.id)),
|
linkedUsers: linkedUsers.filter(
|
||||||
|
(x) => !mapBy(entityUsers, "id").includes(x.id)
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
},
|
||||||
|
sessionOptions
|
||||||
|
);
|
||||||
|
|
||||||
type UserWithRole = User & { role?: Role };
|
type UserWithRole = User & { role?: Role };
|
||||||
|
|
||||||
@@ -102,34 +151,52 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate)
|
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate);
|
||||||
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price)
|
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price);
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency)
|
const [paymentCurrency, setPaymentCurrency] = useState(
|
||||||
|
entity?.payment?.currency
|
||||||
|
);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const canRenameEntity = useEntityPermission(user, entity, "rename_entity")
|
const canRenameEntity = useEntityPermission(user, entity, "rename_entity");
|
||||||
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles")
|
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles");
|
||||||
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity")
|
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity");
|
||||||
|
|
||||||
const canAddMembers = useEntityPermission(user, entity, "add_to_entity")
|
const canAddMembers = useEntityPermission(user, entity, "add_to_entity");
|
||||||
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity")
|
const canRemoveMembers = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"remove_from_entity"
|
||||||
|
);
|
||||||
|
|
||||||
const canAssignRole = useEntityPermission(user, entity, "assign_to_role")
|
const canAssignRole = useEntityPermission(user, entity, "assign_to_role");
|
||||||
const canPay = useEntityPermission(user, entity, 'pay_entity')
|
const canPay = useEntityPermission(user, entity, "pay_entity");
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||||
|
);
|
||||||
|
|
||||||
const removeParticipants = () => {
|
const removeParticipants = () => {
|
||||||
if (selectedUsers.length === 0) return;
|
if (selectedUsers.length === 0) return;
|
||||||
if (!canRemoveMembers) return;
|
if (!canRemoveMembers) return;
|
||||||
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} from this entity?`))
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to remove ${selectedUsers.length} member${
|
||||||
|
selectedUsers.length === 1 ? "" : "s"
|
||||||
|
} from this entity?`
|
||||||
|
)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/entities/${entity.id}/users`, { add: false, members: selectedUsers })
|
.patch(`/api/entities/${entity.id}/users`, {
|
||||||
|
add: false,
|
||||||
|
members: selectedUsers,
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The entity has been updated successfully!");
|
toast.success("The entity has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -145,13 +212,24 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
const addParticipants = () => {
|
const addParticipants = () => {
|
||||||
if (selectedUsers.length === 0) return;
|
if (selectedUsers.length === 0) return;
|
||||||
if (!canAddMembers || !isAdding) return;
|
if (!canAddMembers || !isAdding) return;
|
||||||
if (!confirm(`Are you sure you want to add ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} to this entity?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to add ${selectedUsers.length} member${
|
||||||
|
selectedUsers.length === 1 ? "" : "s"
|
||||||
|
} to this entity?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const defaultRole = findBy(entity.roles, 'isDefault', true)!
|
const defaultRole = findBy(entity.roles, "isDefault", true)!;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/entities/${entity.id}/users`, { add: true, members: selectedUsers, role: defaultRole.id })
|
.patch(`/api/entities/${entity.id}/users`, {
|
||||||
|
add: true,
|
||||||
|
members: selectedUsers,
|
||||||
|
role: defaultRole.id,
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The entity has been updated successfully!");
|
toast.success("The entity has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -206,7 +284,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } })
|
.patch(`/api/entities/${entity.id}`, {
|
||||||
|
payment: { price: paymentPrice, currency: paymentCurrency },
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The entity has been updated successfully!");
|
toast.success("The entity has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -221,9 +301,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
const editLicenses = () => {
|
const editLicenses = () => {
|
||||||
if (!isAdmin(user)) return;
|
if (!isAdmin(user)) return;
|
||||||
|
|
||||||
const licenses = prompt("Update the number of licenses:", (entity.licenses || 0).toString());
|
const licenses = prompt(
|
||||||
|
"Update the number of licenses:",
|
||||||
|
(entity.licenses || 0).toString()
|
||||||
|
);
|
||||||
if (!licenses) return;
|
if (!licenses) return;
|
||||||
if (!parseInt(licenses) || parseInt(licenses) <= 0) return toast.error("Write a valid number of licenses!")
|
if (!parseInt(licenses) || parseInt(licenses) <= 0)
|
||||||
|
return toast.error("Write a valid number of licenses!");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
@@ -259,8 +343,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const assignUsersToRole = (role: string) => {
|
const assignUsersToRole = (role: string) => {
|
||||||
if (!canAssignRole) return
|
if (!canAssignRole) return;
|
||||||
if (selectedUsers.length === 0) return
|
if (selectedUsers.length === 0) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
@@ -274,7 +358,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}
|
};
|
||||||
|
|
||||||
const renderCard = (u: UserWithRole) => {
|
const renderCard = (u: UserWithRole) => {
|
||||||
return (
|
return (
|
||||||
@@ -285,8 +369,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
||||||
"hover:border-mti-purple transition ease-in-out duration-300",
|
"hover:border-mti-purple transition ease-in-out duration-300",
|
||||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
||||||
<img src={u.profilePicture} alt={u.name} />
|
<img src={u.profilePicture} alt={u.name} />
|
||||||
@@ -311,13 +396,17 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<Tooltip tooltip="Expiration Date">
|
<Tooltip tooltip="Expiration Date">
|
||||||
<BsStopwatchFill />
|
<BsStopwatchFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
{u.subscriptionExpirationDate
|
||||||
|
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
|
||||||
|
: "Unlimited"}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="Last Login">
|
<Tooltip tooltip="Last Login">
|
||||||
<BsClockFill />
|
<BsClockFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
{u.lastLogin
|
||||||
|
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||||
|
: "N/A"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -345,10 +434,14 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/entities"
|
href="/entities"
|
||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2>
|
<h2 className="font-bold text-2xl">
|
||||||
|
{entity.label}{" "}
|
||||||
|
{isAdmin(user) && `- ${entity.licenses || 0} licenses`}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isAdmin(user) && canPay && (
|
{!isAdmin(user) && canPay && (
|
||||||
@@ -357,11 +450,15 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!entity.expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(entity.expiryDate),
|
!entity.expiryDate
|
||||||
"bg-white border-mti-gray-platinum",
|
? "!bg-mti-green-ultralight !border-mti-green-light"
|
||||||
)}>
|
: expirationDateColor(entity.expiryDate),
|
||||||
|
"bg-white border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{!entity.expiryDate && "Unlimited"}
|
{!entity.expiryDate && "Unlimited"}
|
||||||
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")}
|
{entity.expiryDate &&
|
||||||
|
moment(entity.expiryDate).format("DD/MM/YYYY")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +466,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={renameGroup}
|
onClick={renameGroup}
|
||||||
disabled={isLoading || !canRenameEntity}
|
disabled={isLoading || !canRenameEntity}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsTag />
|
<BsTag />
|
||||||
<span className="text-xs">Rename Entity</span>
|
<span className="text-xs">Rename Entity</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -377,7 +475,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={editLicenses}
|
onClick={editLicenses}
|
||||||
disabled={isLoading || !isAdmin(user)}
|
disabled={isLoading || !isAdmin(user)}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsHash />
|
<BsHash />
|
||||||
<span className="text-xs">Edit Licenses</span>
|
<span className="text-xs">Edit Licenses</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -385,14 +484,16 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/entities/${entity.id}/roles`)}
|
onClick={() => router.push(`/entities/${entity.id}/roles`)}
|
||||||
disabled={isLoading || !canViewRoles}
|
disabled={isLoading || !canViewRoles}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsPerson />
|
<BsPerson />
|
||||||
<span className="text-xs">Edit Roles</span>
|
<span className="text-xs">Edit Roles</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={deleteGroup}
|
onClick={deleteGroup}
|
||||||
disabled={isLoading || !canDeleteEntity}
|
disabled={isLoading || !canDeleteEntity}
|
||||||
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsTrash />
|
<BsTrash />
|
||||||
<span className="text-xs">Delete Entity</span>
|
<span className="text-xs">Delete Entity</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -410,8 +511,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
|
!expiryDate
|
||||||
"transition duration-300 ease-in-out",
|
? "!bg-mti-green-ultralight !border-mti-green-light"
|
||||||
|
: expirationDateColor(expiryDate),
|
||||||
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
@@ -425,8 +528,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
|
!expiryDate
|
||||||
"bg-white border-mti-gray-platinum",
|
? "!bg-mti-green-ultralight !border-mti-green-light"
|
||||||
|
: expirationDateColor(expiryDate),
|
||||||
|
"bg-white border-mti-gray-platinum"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Unlimited
|
Unlimited
|
||||||
@@ -435,17 +540,21 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={!!expiryDate}
|
isChecked={!!expiryDate}
|
||||||
onChange={(checked: boolean) => setExpiryDate(checked ? entity.expiryDate || new Date() : null)}
|
onChange={(checked: boolean) =>
|
||||||
|
setExpiryDate(
|
||||||
|
checked ? entity.expiryDate || new Date() : null
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Enable expiry date
|
Enable expiry date
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={updateExpiryDate}
|
onClick={updateExpiryDate}
|
||||||
disabled={expiryDate === entity.expiryDate}
|
disabled={expiryDate === entity.expiryDate}
|
||||||
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsCheck />
|
<BsCheck />
|
||||||
<span className="text-xs">Apply Change</span>
|
<span className="text-xs">Apply Change</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -457,25 +566,34 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<div className="w-full max-w-xl flex items-center gap-4">
|
<div className="w-full max-w-xl flex items-center gap-4">
|
||||||
<Input
|
<Input
|
||||||
name="paymentValue"
|
name="paymentValue"
|
||||||
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)}
|
onChange={(e) =>
|
||||||
|
setPaymentPrice(e ? parseInt(e) : undefined)
|
||||||
|
}
|
||||||
type="number"
|
type="number"
|
||||||
defaultValue={entity.payment?.price || 0}
|
defaultValue={entity.payment?.price || 0}
|
||||||
thin
|
thin
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
)}
|
)}
|
||||||
options={CURRENCIES_OPTIONS}
|
options={CURRENCIES_OPTIONS}
|
||||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
value={CURRENCIES_OPTIONS.find(
|
||||||
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)}
|
(c) => c.value === paymentCurrency
|
||||||
|
)}
|
||||||
|
onChange={(value) =>
|
||||||
|
setPaymentCurrency(value?.value ?? undefined)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={updatePayment}
|
onClick={updatePayment}
|
||||||
disabled={!paymentPrice || paymentPrice <= 0 || !paymentCurrency}
|
disabled={
|
||||||
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
!paymentPrice || paymentPrice <= 0 || !paymentCurrency
|
||||||
|
}
|
||||||
|
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsCheck />
|
<BsCheck />
|
||||||
<span className="text-xs">Apply Change</span>
|
<span className="text-xs">Apply Change</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -485,28 +603,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="font-semibold text-xl">Members ({users.length})</span>
|
<span className="font-semibold text-xl">
|
||||||
|
Members ({users.length})
|
||||||
|
</span>
|
||||||
{!isAdding && (
|
{!isAdding && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsAdding(true)}
|
onClick={() => setIsAdding(true)}
|
||||||
disabled={isLoading || !canAddMembers}
|
disabled={isLoading || !canAddMembers}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsPlus />
|
<BsPlus />
|
||||||
<span className="text-xs">Add Members</span>
|
<span className="text-xs">Add Members</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
disabled={isLoading || !canAssignRole || selectedUsers.length === 0}
|
disabled={
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
isLoading || !canAssignRole || selectedUsers.length === 0
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsPerson />
|
<BsPerson />
|
||||||
<span className="text-xs">Assign Role</span>
|
<span className="text-xs">Assign Role</span>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuItems anchor="bottom" className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col">
|
<MenuItems
|
||||||
|
anchor="bottom"
|
||||||
|
className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col"
|
||||||
|
>
|
||||||
{entity.roles.map((role) => (
|
{entity.roles.map((role) => (
|
||||||
<MenuItem key={role.id}>
|
<MenuItem key={role.id}>
|
||||||
<button onClick={() => assignUsersToRole(role.id)} className="p-4 hover:bg-neutral-100 w-32">
|
<button
|
||||||
|
onClick={() => assignUsersToRole(role.id)}
|
||||||
|
className="p-4 hover:bg-neutral-100 w-32"
|
||||||
|
>
|
||||||
{role.label}
|
{role.label}
|
||||||
</button>
|
</button>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -516,8 +646,11 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={removeParticipants}
|
onClick={removeParticipants}
|
||||||
disabled={selectedUsers.length === 0 || isLoading || !canRemoveMembers}
|
disabled={
|
||||||
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
selectedUsers.length === 0 || isLoading || !canRemoveMembers
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsTrash />
|
<BsTrash />
|
||||||
<span className="text-xs">Remove Members</span>
|
<span className="text-xs">Remove Members</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -528,16 +661,22 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsAdding(false)}
|
onClick={() => setIsAdding(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsX />
|
<BsX />
|
||||||
<span className="text-xs">Discard Selection</span>
|
<span className="text-xs">Discard Selection</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={addParticipants}
|
onClick={addParticipants}
|
||||||
disabled={selectedUsers.length === 0 || isLoading || !canAddMembers}
|
disabled={
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
selectedUsers.length === 0 || isLoading || !canAddMembers
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsPlus />
|
<BsPlus />
|
||||||
<span className="text-xs">Add Members ({selectedUsers.length})</span>
|
<span className="text-xs">
|
||||||
|
Add Members ({selectedUsers.length})
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -546,7 +685,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<CardList<User | UserWithRole>
|
<CardList<User | UserWithRole>
|
||||||
list={isAdding ? linkedUsers : users}
|
list={isAdding ? linkedUsers : users}
|
||||||
renderCard={renderCard}
|
renderCard={renderCard}
|
||||||
searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]}
|
searchFields={[
|
||||||
|
["name"],
|
||||||
|
["email"],
|
||||||
|
["corporateInformation", "companyInformation", "name"],
|
||||||
|
["role", "label"],
|
||||||
|
["type"],
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,15 +19,10 @@ import Link from "next/link";
|
|||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {
|
import {BsCheck, BsChevronLeft, BsTag, BsTrash} from "react-icons/bs";
|
||||||
BsCheck,
|
|
||||||
BsChevronLeft,
|
|
||||||
BsTag,
|
|
||||||
BsTrash,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
type PermissionLayout = { label: string, key: RolePermission }
|
type PermissionLayout = {label: string; key: RolePermission};
|
||||||
|
|
||||||
const USER_MANAGEMENT: PermissionLayout[] = [
|
const USER_MANAGEMENT: PermissionLayout[] = [
|
||||||
{label: "View Students", key: "view_students"},
|
{label: "View Students", key: "view_students"},
|
||||||
@@ -49,7 +44,7 @@ const USER_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{label: "Download User List", key: "download_user_list"},
|
{label: "Download User List", key: "download_user_list"},
|
||||||
{label: "View Code List", key: "view_code_list"},
|
{label: "View Code List", key: "view_code_list"},
|
||||||
{label: "Delete Code", key: "delete_code"},
|
{label: "Delete Code", key: "delete_code"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const EXAM_MANAGEMENT: PermissionLayout[] = [
|
const EXAM_MANAGEMENT: PermissionLayout[] = [
|
||||||
{label: "View Reading", key: "view_reading"},
|
{label: "View Reading", key: "view_reading"},
|
||||||
@@ -67,8 +62,12 @@ const EXAM_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{label: "View Level", key: "view_level"},
|
{label: "View Level", key: "view_level"},
|
||||||
{label: "Generate Level", key: "generate_level"},
|
{label: "Generate Level", key: "generate_level"},
|
||||||
{label: "Delete Level", key: "delete_level"},
|
{label: "Delete Level", key: "delete_level"},
|
||||||
|
{label: "Set as Private/Public", key: "update_exam_privacy"},
|
||||||
|
{label: "View Confidential Exams", key: "view_confidential_exams"},
|
||||||
|
{label: "Create Confidential Exams", key: "create_confidential_exams"},
|
||||||
|
{label: "Create Public Exams", key: "create_public_exams"},
|
||||||
{label: "View Statistics", key: "view_statistics"},
|
{label: "View Statistics", key: "view_statistics"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
|
const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
|
||||||
{label: "View Classrooms", key: "view_classrooms"},
|
{label: "View Classrooms", key: "view_classrooms"},
|
||||||
@@ -80,7 +79,7 @@ const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{label: "Delete Classroom", key: "delete_classroom"},
|
{label: "Delete Classroom", key: "delete_classroom"},
|
||||||
{label: "View Student Record", key: "view_student_record"},
|
{label: "View Student Record", key: "view_student_record"},
|
||||||
{label: "Download Student Report", key: "download_student_record"},
|
{label: "Download Student Report", key: "download_student_record"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const ENTITY_MANAGEMENT: PermissionLayout[] = [
|
const ENTITY_MANAGEMENT: PermissionLayout[] = [
|
||||||
{label: "View Entities", key: "view_entities"},
|
{label: "View Entities", key: "view_entities"},
|
||||||
@@ -99,8 +98,8 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{label: "Edit Grading System", key: "edit_grading_system"},
|
{label: "Edit Grading System", key: "edit_grading_system"},
|
||||||
{label: "View Student Performance", key: "view_student_performance"},
|
{label: "View Student Performance", key: "view_student_performance"},
|
||||||
{label: "Pay for Entity", key: "pay_entity"},
|
{label: "Pay for Entity", key: "pay_entity"},
|
||||||
{ label: "View Payment Record", key: "view_payment_record" }
|
{label: "View Payment Record", key: "view_payment_record"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
||||||
{label: "View Assignments", key: "view_assignments"},
|
{label: "View Assignments", key: "view_assignments"},
|
||||||
@@ -109,26 +108,33 @@ const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{label: "Edit Assignments", key: "edit_assignment"},
|
{label: "Edit Assignments", key: "edit_assignment"},
|
||||||
{label: "Delete Assignments", key: "delete_assignment"},
|
{label: "Delete Assignments", key: "delete_assignment"},
|
||||||
{label: "Archive Assignments", key: "archive_assignment"},
|
{label: "Archive Assignments", key: "archive_assignment"},
|
||||||
]
|
];
|
||||||
|
|
||||||
|
const WORKFLOW_MANAGEMENT: PermissionLayout[] = [
|
||||||
|
{label: "View Workflows", key: "view_workflows"},
|
||||||
|
{label: "Configure Workflows", key: "configure_workflows"},
|
||||||
|
{label: "Edit Workflow", key: "edit_workflow"},
|
||||||
|
{label: "Delete Workflow", key: "delete_workflow"},
|
||||||
|
];
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const { id, role } = params as { id: string, role: string };
|
const {id, role} = params as {id: string; role: string};
|
||||||
|
|
||||||
if (!mapBy(user.entities, 'id').includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities")
|
if (!mapBy(user.entities, "id").includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities");
|
||||||
|
|
||||||
const entity = await getEntityWithRoles(id);
|
const entity = await getEntityWithRoles(id);
|
||||||
if (!entity) return redirect("/entities")
|
if (!entity) return redirect("/entities");
|
||||||
|
|
||||||
const entityRole = findBy(entity.roles, 'id', role)
|
const entityRole = findBy(entity.roles, "id", role);
|
||||||
if (!entityRole) return redirect(`/entities/${id}/roles`)
|
if (!entityRole) return redirect(`/entities/${id}/roles`);
|
||||||
|
|
||||||
if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`)
|
if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`);
|
||||||
const disableEdit = !isAdmin(user) && findBy(user.entities, 'id', entity.id)?.role === entityRole.id
|
const disableEdit = !isAdmin(user) && findBy(user.entities, "id", entity.id)?.role === entityRole.id;
|
||||||
|
|
||||||
const userCount = await countEntityUsers(id, {"entities.role": role});
|
const userCount = await countEntityUsers(id, {"entities.role": role});
|
||||||
|
|
||||||
@@ -138,7 +144,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
|
|||||||
entity,
|
entity,
|
||||||
role: entityRole,
|
role: entityRole,
|
||||||
userCount,
|
userCount,
|
||||||
disableEdit
|
disableEdit,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
@@ -148,19 +154,18 @@ interface Props {
|
|||||||
entity: EntityWithRoles;
|
entity: EntityWithRoles;
|
||||||
role: Role;
|
role: Role;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
disableEdit?: boolean
|
disableEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EntityRole({user, entity, role, userCount, disableEdit}: Props) {
|
export default function EntityRole({user, entity, role, userCount, disableEdit}: Props) {
|
||||||
const [permissions, setPermissions] = useState(role.permissions)
|
const [permissions, setPermissions] = useState(role.permissions);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions")
|
const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions");
|
||||||
const canRenameRole = useEntityPermission(user, entity, "rename_entity_role")
|
const canRenameRole = useEntityPermission(user, entity, "rename_entity_role");
|
||||||
const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role")
|
const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role");
|
||||||
|
|
||||||
|
|
||||||
const renameRole = () => {
|
const renameRole = () => {
|
||||||
if (!canRenameRole || disableEdit) return;
|
if (!canRenameRole || disableEdit) return;
|
||||||
@@ -202,7 +207,7 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editPermissions = () => {
|
const editPermissions = () => {
|
||||||
if (!canEditPermissions || disableEdit) return
|
if (!canEditPermissions || disableEdit) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -217,21 +222,23 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}
|
};
|
||||||
|
|
||||||
const enableCheckbox = (permission: RolePermission) => {
|
const enableCheckbox = (permission: RolePermission) => {
|
||||||
if (!canEditPermissions || disableEdit) return false
|
if (!canEditPermissions || disableEdit) return false;
|
||||||
return doesEntityAllow(user, entity, permission)
|
return doesEntityAllow(user, entity, permission);
|
||||||
}
|
};
|
||||||
|
|
||||||
const togglePermissions = (p: RolePermission) => setPermissions(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])
|
const togglePermissions = (p: RolePermission) => setPermissions((prev) => (prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]));
|
||||||
const toggleMultiplePermissions = (p: RolePermission[]) =>
|
const toggleMultiplePermissions = (p: RolePermission[]) =>
|
||||||
setPermissions(prev => [...prev.filter(x => !p.includes(x)), ...(p.every(x => prev.includes(x)) ? [] : p)])
|
setPermissions((prev) => [...prev.filter((x) => !p.includes(x)), ...(p.every((x) => prev.includes(x)) ? [] : p)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{role.label} | {entity.label} | EnCoach</title>
|
<title>
|
||||||
|
{role.label} | {entity.label} | EnCoach
|
||||||
|
</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
@@ -249,7 +256,9 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">{role.label} Role ({userCount} users)</h2>
|
<h2 className="font-bold text-2xl">
|
||||||
|
{role.label} Role ({userCount} users)
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
@@ -286,16 +295,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>User Management</b>
|
<b>User Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(USER_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(USER_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(USER_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(USER_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{USER_MANAGEMENT.map(({label, key}) => (
|
{USER_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -307,16 +319,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Exam Management</b>
|
<b>Exam Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(EXAM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(EXAM_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(EXAM_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(EXAM_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{EXAM_MANAGEMENT.map(({label, key}) => (
|
{EXAM_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -328,16 +343,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Clasroom Management</b>
|
<b>Clasroom Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(CLASSROOM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(CLASSROOM_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(CLASSROOM_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(CLASSROOM_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{CLASSROOM_MANAGEMENT.map(({label, key}) => (
|
{CLASSROOM_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -349,16 +367,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Entity Management</b>
|
<b>Entity Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(ENTITY_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(ENTITY_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(ENTITY_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(ENTITY_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{ENTITY_MANAGEMENT.map(({label, key}) => (
|
{ENTITY_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -370,16 +391,43 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Assignment Management</b>
|
<b>Assignment Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(ASSIGNMENT_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(ASSIGNMENT_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(ASSIGNMENT_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(ASSIGNMENT_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{ASSIGNMENT_MANAGEMENT.map(({label, key}) => (
|
{ASSIGNMENT_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
|
{label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="w-full flex items-center justify-between">
|
||||||
|
<b>Workflow Management</b>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!canEditPermissions || disableEdit}
|
||||||
|
isChecked={mapBy(WORKFLOW_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
|
onChange={() => toggleMultiplePermissions(mapBy(WORKFLOW_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
|
Select all
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{WORKFLOW_MANAGEMENT.map(({label, key}) => (
|
||||||
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -20,21 +20,41 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Divider } from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs";
|
import {
|
||||||
|
BsCheck,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClockFill,
|
||||||
|
BsEnvelopeFill,
|
||||||
|
BsStopwatchFill,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast, ToastContainer } from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
if (!["admin", "developer"].includes(user.type)) return redirect("/entities")
|
if (!["admin", "developer"].includes(user.type)) return redirect("/entities");
|
||||||
|
|
||||||
const users = await getUsers()
|
const users = await getUsers(
|
||||||
|
{ id: { $ne: user.id } },
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
type: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
email: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }),
|
props: serialize({ user, users }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -49,19 +69,31 @@ export default function Home({ user, users }: Props) {
|
|||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [licenses, setLicenses] = useState(0);
|
const [licenses, setLicenses] = useState(0);
|
||||||
|
|
||||||
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users);
|
const { rows, renderSearch } = useListSearch<User>(
|
||||||
|
[["name"], ["email"], ["corporateInformation", "companyInformation", "name"]],
|
||||||
|
users
|
||||||
|
);
|
||||||
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const createGroup = () => {
|
const createGroup = () => {
|
||||||
if (!label.trim()) return;
|
if (!label.trim()) return;
|
||||||
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers })
|
.post<Entity>(`/api/entities`, {
|
||||||
|
label,
|
||||||
|
licenses,
|
||||||
|
members: selectedUsers,
|
||||||
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
toast.success("Your entity has been created successfully!");
|
toast.success("Your entity has been created successfully!");
|
||||||
router.replace(`/entities/${result.data.id}`);
|
router.replace(`/entities/${result.data.id}`);
|
||||||
@@ -73,7 +105,10 @@ export default function Home({ user, users }: Props) {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -93,7 +128,8 @@ export default function Home({ user, users }: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/classrooms"
|
href="/classrooms"
|
||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
|
>
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">Create Entity</h2>
|
<h2 className="font-bold text-2xl">Create Entity</h2>
|
||||||
@@ -102,7 +138,8 @@ export default function Home({ user, users }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={createGroup}
|
onClick={createGroup}
|
||||||
disabled={!label.trim() || licenses <= 0 || isLoading}
|
disabled={!label.trim() || licenses <= 0 || isLoading}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
<BsCheck />
|
<BsCheck />
|
||||||
<span className="text-xs">Create Entity</span>
|
<span className="text-xs">Create Entity</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -112,17 +149,30 @@ export default function Home({ user, users }: Props) {
|
|||||||
<div className="w-full grid grid-cols-2 gap-4">
|
<div className="w-full grid grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<span className="font-semibold text-xl">Entity Label:</span>
|
<span className="font-semibold text-xl">Entity Label:</span>
|
||||||
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" />
|
<Input
|
||||||
|
name="name"
|
||||||
|
onChange={setLabel}
|
||||||
|
type="text"
|
||||||
|
placeholder="Entity A"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<span className="font-semibold text-xl">Licenses:</span>
|
<span className="font-semibold text-xl">Licenses:</span>
|
||||||
<Input name="licenses" min={0} onChange={(v) => setLicenses(parseInt(v))} type="number" placeholder="12" />
|
<Input
|
||||||
|
name="licenses"
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => setLicenses(parseInt(v))}
|
||||||
|
type="number"
|
||||||
|
placeholder="12"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span>
|
<span className="font-semibold text-xl">
|
||||||
|
Members ({selectedUsers.length} selected):
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex items-center gap-4">
|
<div className="w-full flex items-center gap-4">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
@@ -139,15 +189,18 @@ export default function Home({ user, users }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
||||||
"hover:border-mti-purple transition ease-in-out duration-300",
|
"hover:border-mti-purple transition ease-in-out duration-300",
|
||||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
||||||
<img src={u.profilePicture} alt={u.name} />
|
<img src={u.profilePicture} alt={u.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-semibold">{getUserName(u)}</span>
|
<span className="font-semibold">{getUserName(u)}</span>
|
||||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
<span className="opacity-80 text-sm">
|
||||||
|
{USER_TYPE_LABELS[u.type]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,13 +215,17 @@ export default function Home({ user, users }: Props) {
|
|||||||
<Tooltip tooltip="Expiration Date">
|
<Tooltip tooltip="Expiration Date">
|
||||||
<BsStopwatchFill />
|
<BsStopwatchFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
{u.subscriptionExpirationDate
|
||||||
|
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
|
||||||
|
: "Unlimited"}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="Last Login">
|
<Tooltip tooltip="Last Login">
|
||||||
<BsClockFill />
|
<BsClockFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
{u.lastLogin
|
||||||
|
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||||
|
: "N/A"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user